From c558750f9eff62343df3f3513f4d6f8562d676b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 12:14:44 +0200 Subject: [PATCH] fix: method-scoped prototype guards, IPC transports, value profiles, Intl worklist Lands #8672, #8718, #8720 and #8659. #8672's blocker is resolved the way the evidence pointed. Its own `is_bound_native_method_closure_value` is gone; only main's `is_bound_native_constructor_closure_value` remains, and the branch that called it in `parent_static.rs` is deleted. That branch was unreachable under either predicate -- the `if let Some(..) = bound_native_callable_ module_and_method(..)` block directly above returns unconditionally, and both predicates require that same query to be `Some` -- so removing it is behaviour-preserving rather than a choice between two semantics. #8718 (closes #6620) routes `server.listen(path)`, `net.connect(path)` and the `{ path }` overloads through real Windows named pipes and Unix-domain sockets instead of falling back to TCP. #8720 stabilizes native value profile boundaries; #8659 completes the Intl 402 test262 worklist. One fix on top: a changelog fragment for #8718, which had neither one nor a skip-changelog label. #8719 is NOT in this batch -- it conflicts with #8672 on `lower_call/method_override.rs`, which both touch. No version bump. --- Cargo.lock | 1 + changelog.d/8659-intl402-worklist.md | 1 + .../8672-method-name-prototype-guards.md | 9 + .../8718-named-pipe-unix-socket-ipc.md | 1 + changelog.d/8720-native-value-profile.md | 6 + crates/perry-api-manifest/src/native_abi.rs | 56 +- .../collectors/proven_this_routing_tests.rs | 16 +- .../perry-codegen/src/expr/literals_vars.rs | 5 +- crates/perry-codegen/src/expr/mod.rs | 19 +- crates/perry-codegen/src/expr/pod_record.rs | 249 ++++- .../src/lower_call/extern_func.rs | 224 ++++- .../src/lower_call/method_override.rs | 29 +- .../src/lower_call/native_table/net_events.rs | 4 +- .../src/lower_call/omitted_native_params.rs | 11 +- .../property_get/dynamic_dispatch.rs | 7 +- .../src/native_value/materialize.rs | 40 +- crates/perry-codegen/src/native_value/pod.rs | 45 +- .../src/native_value/verify/abi.rs | 10 + crates/perry-codegen/src/runtime_decls/mod.rs | 7 + .../src/runtime_decls/objects.rs | 14 +- crates/perry-codegen/src/stmt/let_stmt.rs | 42 +- .../native_library.rs | 96 +- .../native_proof_regressions/pod_manifest.rs | 104 +++ crates/perry-ext-net/src/dispatch.rs | 8 +- crates/perry-ext-net/src/ipc.rs | 407 +++++++++ crates/perry-ext-net/src/lib.rs | 249 +++-- crates/perry-ext-net/src/server_state.rs | 37 +- crates/perry-ext-net/src/test_async_shims.rs | 69 +- crates/perry-ext-net/src/transport.rs | 13 + crates/perry-hir/src/lib.rs | 2 + crates/perry-hir/src/native_profile.rs | 259 ++++++ crates/perry-runtime/Cargo.toml | 24 +- crates/perry-runtime/src/array/push_pop.rs | 40 +- crates/perry-runtime/src/array/tests.rs | 14 + crates/perry-runtime/src/intl.rs | 862 +++++++++--------- .../perry-runtime/src/intl/canon_aliases.rs | 103 ++- .../perry-runtime/src/intl/date_collator.rs | 209 ++++- .../src/intl/date_collator/compare.rs | 148 +++ .../perry-runtime/src/intl/display_names.rs | 40 +- .../perry-runtime/src/intl/duration_format.rs | 61 +- .../src/intl/list_relative_plural.rs | 465 +++++++++- crates/perry-runtime/src/intl/locale.rs | 47 +- .../src/intl/locale/likely_subtags.rs | 96 +- crates/perry-runtime/src/intl/locales.rs | 91 +- .../perry-runtime/src/intl/method_install.rs | 131 +++ .../src/intl/number_format_options.rs | 148 +-- .../perry-runtime/src/intl/rooted_fields.rs | 94 ++ crates/perry-runtime/src/intl/segmenter.rs | 82 +- crates/perry-runtime/src/native_abi.rs | 123 +++ .../src/object/class_registry.rs | 21 +- .../src/object/class_registry/construct.rs | 19 +- .../src/object/class_registry/gc_roots.rs | 4 + .../class_registry/prototype_methods.rs | 98 +- .../src/object/class_registry/state.rs | 48 +- .../perry-runtime/src/object/delete_rest.rs | 12 + .../src/object/descriptor_state.rs | 24 +- .../src/object/native_call_method.rs | 2 +- .../native_call_method/handle_methods.rs | 90 +- .../src/typed_feedback/guards.rs | 18 +- .../perry-runtime/src/typed_feedback/tests.rs | 69 +- .../src/common/dispatch/fastify_net_zlib.rs | 13 +- crates/perry-stdlib/src/net/mod.rs | 45 +- .../src/async_to_generator_tests.rs | 2 - crates/perry-transform/src/inline/analysis.rs | 21 +- crates/perry-transform/src/inline/mod.rs | 22 +- .../compile/collect_modules/feature_detect.rs | 8 +- .../compile/resolve/native_library.rs | 320 ++++++- docs/api/manifest.schema.json | 60 +- docs/src/language/native-values.md | 35 +- docs/src/native-libraries/manifest-v1.md | 60 +- scripts/ci_e2e_scope.py | 2 + scripts/unrooted_local_shape_baseline.json | 12 +- .../test_method_guard_name_invalidation.ts | 67 ++ .../test_parity_native_value_profile.ts | 17 + 74 files changed, 4785 insertions(+), 1122 deletions(-) create mode 100644 changelog.d/8659-intl402-worklist.md create mode 100644 changelog.d/8672-method-name-prototype-guards.md create mode 100644 changelog.d/8718-named-pipe-unix-socket-ipc.md create mode 100644 changelog.d/8720-native-value-profile.md create mode 100644 crates/perry-ext-net/src/ipc.rs create mode 100644 crates/perry-hir/src/native_profile.rs create mode 100644 crates/perry-runtime/src/intl/date_collator/compare.rs create mode 100644 crates/perry-runtime/src/intl/method_install.rs create mode 100644 crates/perry-runtime/src/intl/rooted_fields.rs create mode 100644 test-files/test_method_guard_name_invalidation.ts diff --git a/Cargo.lock b/Cargo.lock index 8f72b324fa..6c8a98c010 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6310,6 +6310,7 @@ dependencies = [ "hostname", "icu_calendar", "icu_datetime", + "icu_locale", "icu_locale_core", "icu_time", "idna", diff --git a/changelog.d/8659-intl402-worklist.md b/changelog.d/8659-intl402-worklist.md new file mode 100644 index 0000000000..7466b856cf --- /dev/null +++ b/changelog.d/8659-intl402-worklist.md @@ -0,0 +1 @@ +Completed the #5896 Test262 Intl402 worklist. Locale canonicalization now applies ICU4X CLDR aliases and likely-subtag data, Intl constructors consistently handle proxy-backed locale and option objects, Collator/PluralRules/RelativeTimeFormat/Segmenter behavior matches the listed ECMA-402 cases, derived Intl classes preserve their native prototypes, and maximum-length arrays stay logically sparse. All 101 pinned worklist tests now pass. diff --git a/changelog.d/8672-method-name-prototype-guards.md b/changelog.d/8672-method-name-prototype-guards.md new file mode 100644 index 0000000000..ae0bd97cc7 --- /dev/null +++ b/changelog.d/8672-method-name-prototype-guards.md @@ -0,0 +1,9 @@ +--- +category: Performance +title: Restore method-scoped prototype guards +--- + +Prototype mutation now invalidates direct-call guards by method-name slot +instead of permanently disabling every method guard in the process. Hash +collisions remain conservative, and dynamic prototype replacement retains a +global fail-closed escape hatch. diff --git a/changelog.d/8718-named-pipe-unix-socket-ipc.md b/changelog.d/8718-named-pipe-unix-socket-ipc.md new file mode 100644 index 0000000000..1a25e9680a --- /dev/null +++ b/changelog.d/8718-named-pipe-unix-socket-ipc.md @@ -0,0 +1 @@ +Added local IPC transports to `node:net` (closes #6620). `server.listen(path)`, `net.connect(path)` and the `{ path }` overloads now route through a real Windows named pipe or Unix-domain socket rather than falling back to TCP, reusing the existing socket lifecycle. Connection ordering, connection limits and drop events, close cleanup, `server.address()` and deferred `Socket.connect()` behaviour are preserved, with platform round-trip coverage added. diff --git a/changelog.d/8720-native-value-profile.md b/changelog.d/8720-native-value-profile.md new file mode 100644 index 0000000000..e2cd59e6f4 --- /dev/null +++ b/changelog.d/8720-native-value-profile.md @@ -0,0 +1,6 @@ +### Added + +- Stabilized the native value profile with checked exact-width scalars, + source-linked and nested POD layouts, and value-copy semantics across local + assignments and ordinary function boundaries. Invalid or imprecise native + crossings now fail explicitly instead of truncating or losing precision. diff --git a/crates/perry-api-manifest/src/native_abi.rs b/crates/perry-api-manifest/src/native_abi.rs index acc8898d41..aa926b3758 100644 --- a/crates/perry-api-manifest/src/native_abi.rs +++ b/crates/perry-api-manifest/src/native_abi.rs @@ -226,6 +226,10 @@ pub enum NativeAbiType { Json, /// JavaScript truthiness lowered to a C `i32` boolean slot. Bool, + /// Signed 8-bit integer slot. + I8, + /// Signed 16-bit integer slot. + I16, /// Signed 32-bit integer slot. I32, /// Signed 64-bit integer slot. @@ -233,6 +237,11 @@ pub enum NativeAbiType { /// Legacy string return where the native function returns the string /// pointer as an `i64` instead of a C pointer. I64String, + /// Unsigned 8-bit integer slot. The manifest spelling `byte` is accepted + /// as an alias and canonicalizes to `u8`. + U8, + /// Unsigned 16-bit integer slot. + U16, /// Unsigned 32-bit integer slot. U32, /// Unsigned 64-bit integer slot. @@ -240,6 +249,9 @@ pub enum NativeAbiType { /// Pointer-sized unsigned integer slot. Perry's native runtime targets are /// currently 64-bit, so this lowers as an LLVM `i64`. USize, + /// Pointer-sized signed integer slot. Perry's native runtime targets are + /// currently 64-bit, so this lowers as an LLVM `i64`. + ISize, /// 32-bit float slot. F32, /// 64-bit float slot. The legacy manifest spelling `"number"` is accepted @@ -280,12 +292,17 @@ impl NativeAbiType { "string" => Ok(Self::String), "json" => Ok(Self::Json), "bool" | "boolean" => Ok(Self::Bool), + "i8" => Ok(Self::I8), + "i16" => Ok(Self::I16), "i32" => Ok(Self::I32), "i64" => Ok(Self::I64), "i64_str" => Ok(Self::I64String), + "u8" | "byte" => Ok(Self::U8), + "u16" => Ok(Self::U16), "u32" => Ok(Self::U32), "u64" => Ok(Self::U64), "usize" => Ok(Self::USize), + "isize" => Ok(Self::ISize), "f32" => Ok(Self::F32), "f64" | "number" => Ok(Self::F64), "ptr" => Ok(Self::Ptr), @@ -338,12 +355,17 @@ impl NativeAbiType { Self::String => "string", Self::Json => "json", Self::Bool => "bool", + Self::I8 => "i8", + Self::I16 => "i16", Self::I32 => "i32", Self::I64 => "i64", Self::I64String => "i64_str", + Self::U8 => "u8", + Self::U16 => "u16", Self::U32 => "u32", Self::U64 => "u64", Self::USize => "usize", + Self::ISize => "isize", Self::F32 => "f32", Self::F64 => "f64", Self::Ptr => "ptr", @@ -419,11 +441,16 @@ impl NativeAbiType { pub fn is_valid_pod_field(&self) -> bool { matches!( self, - Self::I32 + Self::I8 + | Self::I16 + | Self::I32 | Self::I64 + | Self::U8 + | Self::U16 | Self::U32 | Self::U64 | Self::USize + | Self::ISize | Self::F32 | Self::F64 | Self::BufferLen @@ -464,11 +491,16 @@ impl NativeAbiType { Self::Pod(_) => "object", Self::PodAndCount(_) => "PerryPodView", Self::BufferAndLen => "Buffer", - Self::I32 + Self::I8 + | Self::I16 + | Self::I32 | Self::I64 + | Self::U8 + | Self::U16 | Self::U32 | Self::U64 | Self::USize + | Self::ISize | Self::F32 | Self::F64 | Self::BufferLen @@ -576,4 +608,24 @@ mod tests { // Not a scalar POD field. assert!(!json.is_valid_pod_field()); } + + #[test] + fn exact_width_scalar_spellings_are_canonical_and_pod_safe() { + for (spelling, expected, canonical) in [ + ("i8", NativeAbiType::I8, "i8"), + ("i16", NativeAbiType::I16, "i16"), + ("u8", NativeAbiType::U8, "u8"), + ("byte", NativeAbiType::U8, "u8"), + ("u16", NativeAbiType::U16, "u16"), + ("isize", NativeAbiType::ISize, "isize"), + ] { + let parsed = NativeAbiType::parse_str(spelling).expect("exact-width descriptor"); + assert_eq!(parsed, expected); + assert_eq!(parsed.canonical_kind(), canonical); + assert!(parsed.is_valid_param()); + assert!(parsed.is_valid_return()); + assert!(parsed.is_valid_pod_field()); + assert_eq!(parsed.js_type_name(), "number"); + } + } } diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs index f0f113833d..a6bb8193fc 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -692,11 +692,11 @@ fn guarded_pshape_call_site_is_preceded_by_a_shape_id_guard() { } /// The single-pair shape-only arm is small enough to inline at the call site. -/// Pin the complete safety gate: acquire the prototype-mutation latch, accept -/// both the boxed-pointer and internal raw-pointer ABIs, reject addresses -/// outside the target heap range before dereference, reject own descriptors, -/// then compare the exact class/ShapeId pair. The out-of-line guard must be -/// absent from this caller. +/// Pin the complete safety gate: acquire both the all-method escape latch and +/// the FNV-indexed method-name latch, accept both the boxed-pointer and +/// internal raw-pointer ABIs, reject addresses outside the target heap range +/// before dereference, reject own descriptors, then compare the exact +/// class/ShapeId pair. The out-of-line guard must be absent from this caller. #[test] fn single_arm_method_shape_guard_is_inlined_with_the_runtime_contract() { let ir = emit(&guarded_site_module(), false); @@ -707,6 +707,12 @@ fn single_arm_method_shape_guard_is_inlined_with_the_runtime_contract() { ), "the inline guard must acquire the runtime's release-published sticky latch:\n{probe}" ); + assert!( + probe.contains( + "getelementptr i8, ptr @PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD", + ) && probe.matches("load atomic i8").count() >= 2, + "the inline guard must acquire its method-name invalidation byte:\n{probe}" + ); assert!( !probe.contains("call i32 @js_method_direct_shape_guard("), "a monomorphic shape-only site must not retain the out-of-line guard call:\n{probe}" diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index ef58ba5166..3c28430864 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -13,7 +13,6 @@ use crate::lower_string_concat::{ lower_string_self_append_chain, }; use crate::nanbox::double_literal; -use crate::native_value::MaterializationReason; use crate::type_analysis::{is_map_expr, is_set_expr, receiver_class_name}; use crate::types::{DOUBLE, I32, I64}; @@ -21,7 +20,7 @@ use super::{ can_lower_expr_as_i32_in_current_region, emit_root_nanbox_store_on_block, emit_shadow_slot_clear, emit_shadow_slot_update_for_expr, emit_write_barrier, is_global_this_builtin_function_name, lower_expr, lower_expr_as_i32, - lower_pod_local_reassignment, materialize_pod_local, nanbox_string_inline, FnCtx, + lower_pod_local_reassignment, materialize_pod_value_copy, nanbox_string_inline, FnCtx, TrustedBoxCapturePtr, }; @@ -439,7 +438,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // module-scope `let`s (the ones in `hir.init` at top level). Expr::LocalGet(id) => { if ctx.pod_records.contains_key(id) { - return materialize_pod_local(ctx, *id, MaterializationReason::PodMaterialization); + return materialize_pod_value_copy(ctx, *id); } // Captured by closure (from outer scope): if let Some(&capture_idx) = ctx.closure_captures.get(id) { diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index a58645e92e..b12c1edbbe 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -99,8 +99,9 @@ pub(crate) use nanbox_inline::{ pub(crate) use native_record::{array_kind_fact, effect_fact, raw_f64_layout_fact}; pub(crate) use object_literal::lower_object_literal; pub(crate) use pod_record::{ - lower_and_store_initial_pod_field, lower_pod_local_reassignment, materialize_pod_local, - try_lower_pod_field_get, try_lower_pod_field_set, + copy_pod_local, lower_and_store_initial_pod_field, lower_pod_local_reassignment, + materialize_pod_local, materialize_pod_value_copy, try_lower_pod_field_get, + try_lower_pod_field_set, }; pub(crate) use proven_view_access::{ index_is_exact_i32_shape, local_is_proven_int_store_view, @@ -2873,10 +2874,16 @@ fn native_number_to_f64(ctx: &mut FnCtx<'_>, lowered: &LoweredValue) -> Option { Some(ctx.block().uitofp(I32, &lowered.value, DOUBLE)) } - NativeRep::I64 | NativeRep::ISize => Some(ctx.block().sitofp(I64, &lowered.value, DOUBLE)), - NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - Some(ctx.block().uitofp(I64, &lowered.value, DOUBLE)) - } + NativeRep::I64 | NativeRep::ISize => Some(ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + )), + NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => Some(ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + )), _ => None, } } diff --git a/crates/perry-codegen/src/expr/pod_record.rs b/crates/perry-codegen/src/expr/pod_record.rs index 67033cbf06..522a189863 100644 --- a/crates/perry-codegen/src/expr/pod_record.rs +++ b/crates/perry-codegen/src/expr/pod_record.rs @@ -6,7 +6,7 @@ use crate::nanbox::{double_literal, POINTER_MASK_I64, TAG_UNDEFINED_I64}; use crate::native_value::{ field_expected_rep, llvm_type_for_native_rep, materialize_js_value, BufferAccessMode, LoweredValue, MaterializationReason, NativeRep, NativeValueState, PodLayoutField, - PodLayoutManifest, SemanticKind, + PodLayoutManifest, PodLocal, SemanticKind, }; use crate::type_analysis::expr_may_return_boxed_value_from_raw_f64_fallback; use crate::types::{DOUBLE, F32, I16, I32, I64, I8}; @@ -15,6 +15,198 @@ use super::{ emit_root_nanbox_store_on_block, lower_expr, lower_expr_native, nanbox_pointer_inline, FnCtx, }; +pub(crate) fn copy_pod_local( + ctx: &mut FnCtx<'_>, + destination_id: u32, + source_id: u32, +) -> Result> { + let Some(source) = ctx.pod_records.get(&source_id).cloned() else { + return Ok(None); + }; + let data_slot = ctx + .func + .alloca_entry_bytes_aligned(source.layout.size, source.layout.alignment); + let materialized_slot = ctx.func.alloca_entry(DOUBLE); + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.func + .entry_allocas_push_store(DOUBLE, &undef, &materialized_slot); + + let current = ctx.block().load(DOUBLE, &source.materialized_slot); + let current_bits = ctx.block().bitcast_double_to_i64(¤t); + let source_is_native = ctx.block().icmp_eq(I64, ¤t_bits, TAG_UNDEFINED_I64); + let native_idx = ctx.new_block("pod.copy.native"); + let materialized_idx = ctx.new_block("pod.copy.materialized"); + let merge_idx = ctx.new_block("pod.copy.merge"); + let native_label = ctx.block_label(native_idx); + let materialized_label = ctx.block_label(materialized_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block() + .cond_br(&source_is_native, &native_label, &materialized_label); + + // A still-native source can be copied field-for-field without forcing + // i64/u64 through a JavaScript number or allocating an object. + ctx.current_block = native_idx; + for field in &source.layout.fields { + let native = load_pod_field_native( + ctx, + source_id, + &source.data_slot, + field, + "pod_record_copy_source_native", + ); + store_pod_field_native(ctx, destination_id, &data_slot, field, &native); + } + ctx.block().br(&merge_label); + + // Once the source has materialized, its ordinary object is authoritative. + // Revalidate every declared scalar while snapshotting it back into the + // destination's independent native storage. + ctx.current_block = materialized_idx; + let source_handle = unbox_object_handle(ctx, ¤t); + for field in &source.layout.fields { + let value = load_materialized_pod_field_path(ctx, &source_handle, &field.path); + let native = strict_pod_copy_field(ctx, &value, field); + store_pod_field_native(ctx, destination_id, &data_slot, field, &native); + } + ctx.block().br(&merge_label); + ctx.current_block = merge_idx; + + let lowered = LoweredValue { + semantic: SemanticKind::PodRecord, + rep: NativeRep::PodRecord { + layout_id: source.layout.layout_id.clone(), + size: source.layout.size, + alignment: source.layout.alignment, + }, + llvm_ty: crate::types::PTR, + value: data_slot.clone(), + }; + ctx.record_lowered_value( + "PodRecordCopyInit", + Some(destination_id), + "pod_record_value_copy", + &lowered, + None, + None, + Some(MaterializationReason::PodMaterialization), + false, + false, + vec![ + format!("layout_id={}", source.layout.layout_id), + format!("source_local={source_id}"), + "assignment_semantics=copy".to_string(), + "source_representation=native_or_materialized".to_string(), + ], + ); + if let Some(record) = ctx.native_rep_records.last_mut() { + record.pod_layout = Some(source.layout.clone()); + } + Ok(Some(PodLocal { + layout: source.layout, + data_slot, + materialized_slot, + })) +} + +fn load_materialized_pod_field_path( + ctx: &mut FnCtx<'_>, + object_handle: &str, + path: &[String], +) -> String { + let mut current_object = object_handle.to_string(); + let mut result = double_literal(f64::NAN); + for (index, part) in path.iter().enumerate() { + let key = interned_key_handle(ctx, part); + result = ctx.block().call( + DOUBLE, + "js_object_get_field_by_name_f64", + &[(I64, ¤t_object), (I64, &key)], + ); + if index + 1 != path.len() { + current_object = + ctx.block() + .call(I64, "js_native_abi_check_pod_object", &[(DOUBLE, &result)]); + } + } + result +} + +fn strict_pod_copy_field(ctx: &mut FnCtx<'_>, value: &str, field: &PodLayoutField) -> LoweredValue { + match field.native_rep { + NativeRep::I8 => LoweredValue::i8(ctx.block().call( + I8, + "js_native_abi_check_i8", + &[(DOUBLE, value)], + )), + NativeRep::I16 => LoweredValue::i16(ctx.block().call( + I16, + "js_native_abi_check_i16", + &[(DOUBLE, value)], + )), + NativeRep::I32 => LoweredValue::i32(ctx.block().call( + I32, + "js_native_abi_check_i32", + &[(DOUBLE, value)], + )), + NativeRep::I64 => LoweredValue::i64(ctx.block().call( + I64, + "js_native_abi_check_i64", + &[(DOUBLE, value)], + )), + NativeRep::U8 => LoweredValue::u8(ctx.block().call( + I8, + "js_native_abi_check_u8", + &[(DOUBLE, value)], + )), + NativeRep::U16 => LoweredValue::u16(ctx.block().call( + I16, + "js_native_abi_check_u16", + &[(DOUBLE, value)], + )), + NativeRep::U32 => LoweredValue::u32(ctx.block().call( + I32, + "js_native_abi_check_u32", + &[(DOUBLE, value)], + )), + NativeRep::U64 => LoweredValue::u64(ctx.block().call( + I64, + "js_native_abi_check_u64", + &[(DOUBLE, value)], + )), + NativeRep::ISize => LoweredValue::isize(ctx.block().call( + I64, + "js_native_abi_check_isize", + &[(DOUBLE, value)], + )), + NativeRep::USize => LoweredValue::usize(ctx.block().call( + I64, + "js_native_abi_check_usize", + &[(DOUBLE, value)], + )), + NativeRep::F32 => LoweredValue::f32(ctx.block().call( + F32, + "js_native_abi_check_f32", + &[(DOUBLE, value)], + )), + NativeRep::F64 => LoweredValue::f64(ctx.block().call( + DOUBLE, + "js_native_abi_check_f64", + &[(DOUBLE, value)], + )), + NativeRep::BufferLen => LoweredValue::buffer_len(ctx.block().call( + I32, + "js_native_abi_check_u32", + &[(DOUBLE, value)], + )), + NativeRep::HandleId => LoweredValue::handle_id(ctx.block().call( + I64, + "js_native_abi_check_u64", + &[(DOUBLE, value)], + )), + ref other => unreachable!("POD copy contained non-scalar field {other:?}"), + } +} + pub(crate) fn materialize_pod_local( ctx: &mut FnCtx<'_>, local_id: u32, @@ -33,6 +225,28 @@ pub(crate) fn materialize_pod_local( )) } +pub(crate) fn materialize_pod_value_copy(ctx: &mut FnCtx<'_>, local_id: u32) -> Result { + let value = materialize_pod_local(ctx, local_id, MaterializationReason::PodMaterialization)?; + let cloned = ctx + .block() + .call(DOUBLE, "js_structured_clone", &[(DOUBLE, &value)]); + let lowered = LoweredValue::js_value(cloned.clone()); + ctx.record_lowered_value_with_access_mode( + "PodRecordValueRead", + Some(local_id), + "pod_record_materialized_value_copy", + &lowered, + None, + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::PodMaterialization), + false, + false, + vec!["value_semantics=copy_at_managed_boundary".to_string()], + ); + Ok(cloned) +} + pub(crate) fn try_lower_pod_field_get( ctx: &mut FnCtx<'_>, local_id: u32, @@ -341,8 +555,37 @@ fn materialize_pod_parts( let obj_handle = ctx .block() .call(I64, "js_object_alloc", &[(I32, "0"), (I32, &field_count)]); + let mut nested_objects = std::collections::HashMap::, String>::new(); + nested_objects.insert(Vec::new(), obj_handle.clone()); for field in &layout.fields { - let key_handle = interned_key_handle(ctx, &field.name); + let mut parent_path = Vec::new(); + for part in field.path.iter().take(field.path.len().saturating_sub(1)) { + let mut child_path = parent_path.clone(); + child_path.push(part.clone()); + if !nested_objects.contains_key(&child_path) { + let child = + ctx.block() + .call(I64, "js_object_alloc", &[(I32, "0"), (I32, &field_count)]); + let child_value = nanbox_pointer_inline(ctx.block(), &child); + let parent = nested_objects + .get(&parent_path) + .expect("POD materialization parent exists") + .clone(); + let key = interned_key_handle(ctx, part); + ctx.block().call_void( + "js_object_set_field_by_name", + &[(I64, &parent), (I64, &key), (DOUBLE, &child_value)], + ); + nested_objects.insert(child_path.clone(), child); + } + parent_path = child_path; + } + let parent = nested_objects + .get(&parent_path) + .expect("POD materialization object exists") + .clone(); + let property = field.path.last().unwrap_or(&field.name); + let key_handle = interned_key_handle(ctx, property); let value_js = load_pod_field_as_js( ctx, local_id, @@ -353,7 +596,7 @@ fn materialize_pod_parts( ); ctx.block().call_void( "js_object_set_field_by_name", - &[(I64, &obj_handle), (I64, &key_handle), (DOUBLE, &value_js)], + &[(I64, &parent), (I64, &key_handle), (DOUBLE, &value_js)], ); } let created_value = nanbox_pointer_inline(ctx.block(), &obj_handle); diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index 456d76a051..c04d15dad8 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -1,8 +1,5 @@ -//! Cross-module function call via `Expr::ExternFuncRef` — covers -//! built-in extern names (setTimeout, setInterval, gc, jsx, …), -//! perry/system + perry/updater + perry/background dispatch via the -//! `lower_perry_ui_table_call` machinery, V8-fallback bridge calls, -//! and the generic `perry_fn___` consumer-prefix path. +//! Cross-module `Expr::ExternFuncRef` calls: built-in externs, Perry dispatch +//! tables, V8 fallback bridges, and generic `perry_fn___` calls. use super::builtin_table_gate::callee_is_from_perry_module; use anyhow::{anyhow, Result}; @@ -22,7 +19,7 @@ use crate::native_value::{ NativeAbiTypeRecord, NativeRep, PodLayoutManifest, PodRecordViewManifest, SemanticKind, }; use crate::type_analysis::{is_array_expr, is_string_expr}; -use crate::types::{DOUBLE, F32, I1, I32, I64, I8, PTR, VOID}; +use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8, PTR, VOID}; use super::{ lower_perry_ui_table_call, perry_background_table_lookup, perry_system_table_lookup, @@ -71,6 +68,33 @@ fn record_native_abi_return( ); } +fn materialize_checked_integer_return( + ctx: &mut FnCtx<'_>, + raw: &str, + helper: &'static str, + descriptor: &NativeAbiType, +) -> String { + let value = ctx.block().call(DOUBLE, helper, &[(I64, raw)]); + let lowered = LoweredValue::js_value(value.clone()); + ctx.record_lowered_value( + "NativeLibraryReturnMaterialize", + None, + "native_library.checked_integer_return", + &lowered, + None, + None, + Some(MaterializationReason::ReturnAbi), + false, + false, + vec![ + format!("descriptor={}", descriptor.canonical_kind()), + format!("guard={helper}"), + "requirement=exact_js_safe_integer".to_string(), + ], + ); + value +} + pub(super) fn lower_buffer_and_len_param( ctx: &mut FnCtx<'_>, descriptor: &NativeAbiType, @@ -286,6 +310,18 @@ fn lower_pod_field_from_js_value( value: &str, ) -> LoweredValue { let (helper, lowered) = match &field.native_rep { + NativeRep::I8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_i8", &[(DOUBLE, value)]); + ("js_native_abi_check_i8", LoweredValue::i8(raw)) + } + NativeRep::I16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_i16", &[(DOUBLE, value)]); + ("js_native_abi_check_i16", LoweredValue::i16(raw)) + } NativeRep::I32 => { let raw = ctx .block() @@ -298,6 +334,18 @@ fn lower_pod_field_from_js_value( .call(I64, "js_native_abi_check_i64", &[(DOUBLE, value)]); ("js_native_abi_check_i64", LoweredValue::i64(raw)) } + NativeRep::U8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_u8", &[(DOUBLE, value)]); + ("js_native_abi_check_u8", LoweredValue::u8(raw)) + } + NativeRep::U16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_u16", &[(DOUBLE, value)]); + ("js_native_abi_check_u16", LoweredValue::u16(raw)) + } NativeRep::U32 => { let raw = ctx .block() @@ -316,6 +364,12 @@ fn lower_pod_field_from_js_value( .call(I64, "js_native_abi_check_usize", &[(DOUBLE, value)]); ("js_native_abi_check_usize", LoweredValue::usize(raw)) } + NativeRep::ISize => { + let raw = ctx + .block() + .call(I64, "js_native_abi_check_isize", &[(DOUBLE, value)]); + ("js_native_abi_check_isize", LoweredValue::isize(raw)) + } NativeRep::F32 => { let raw = ctx .block() @@ -883,6 +937,40 @@ pub(super) fn lower_manifest_param( lowered.push(raw); arg_types.push(I32); } + NativeAbiType::I8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_i8", &[(DOUBLE, val)]); + let native = LoweredValue::i8(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_i8", "int8_range")), + "i8.checked", + ); + lowered.push(raw); + arg_types.push(I8); + } + NativeAbiType::I16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_i16", &[(DOUBLE, val)]); + let native = LoweredValue::i16(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_i16", "int16_range")), + "i16.checked", + ); + lowered.push(raw); + arg_types.push(I16); + } NativeAbiType::I32 => { let raw = ctx .block() @@ -917,6 +1005,40 @@ pub(super) fn lower_manifest_param( lowered.push(raw); arg_types.push(I64); } + NativeAbiType::U8 => { + let raw = ctx + .block() + .call(I8, "js_native_abi_check_u8", &[(DOUBLE, val)]); + let native = LoweredValue::u8(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_u8", "uint8_range")), + "u8.checked", + ); + lowered.push(raw); + arg_types.push(I8); + } + NativeAbiType::U16 => { + let raw = ctx + .block() + .call(I16, "js_native_abi_check_u16", &[(DOUBLE, val)]); + let native = LoweredValue::u16(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_u16", "uint16_range")), + "u16.checked", + ); + lowered.push(raw); + arg_types.push(I16); + } NativeAbiType::U32 | NativeAbiType::BufferLen => { let raw = ctx .block() @@ -962,6 +1084,23 @@ pub(super) fn lower_manifest_param( lowered.push(raw); arg_types.push(I64); } + NativeAbiType::ISize => { + let raw = ctx + .block() + .call(I64, "js_native_abi_check_isize", &[(DOUBLE, val)]); + let native = LoweredValue::isize(raw.clone()); + record_native_abi_param( + ctx, + descriptor, + js_argument_index, + abi_slot_index, + &native, + Some(("js_native_abi_check_isize", "safe_integer_isize")), + "isize.checked", + ); + lowered.push(raw); + arg_types.push(I64); + } NativeAbiType::F32 => { let raw = ctx .block() @@ -1480,11 +1619,14 @@ pub fn try_lower_extern_func_call( || name.contains("file_dialog"))); let returns_void = matches!(manifest_ret, Some(NativeAbiType::Void)) || (manifest_ret.is_none() && matches!(ext_return_type, HirType::Void)); + let returns_i8 = matches!(manifest_ret, Some(NativeAbiType::I8 | NativeAbiType::U8)); + let returns_i16 = matches!(manifest_ret, Some(NativeAbiType::I16 | NativeAbiType::U16)); let returns_i32 = matches!(manifest_ret, Some(NativeAbiType::I32 | NativeAbiType::Bool)); let returns_i64 = matches!(manifest_ret, Some(NativeAbiType::I64)); let returns_u32 = matches!(manifest_ret, Some(NativeAbiType::U32)); let returns_u64 = matches!(manifest_ret, Some(NativeAbiType::U64)); let returns_usize = matches!(manifest_ret, Some(NativeAbiType::USize)); + let returns_isize = matches!(manifest_ret, Some(NativeAbiType::ISize)); let returns_f32 = matches!(manifest_ret, Some(NativeAbiType::F32)); let returns_buffer_len = matches!(manifest_ret, Some(NativeAbiType::BufferLen)); let returns_handle = matches!(manifest_ret, Some(NativeAbiType::Handle(_))); @@ -1522,6 +1664,38 @@ pub fn try_lower_extern_func_call( } let boxed = nanbox_string_inline(ctx.block(), &ptr_i64); return Ok(Some(boxed)); + } else if returns_i8 { + ctx.pending_declares.push((name.clone(), I8, arg_types)); + let raw = ctx.block().call(I8, name, &arg_slices); + let lowered = if matches!(manifest_ret, Some(NativeAbiType::U8)) { + LoweredValue::u8(raw.clone()) + } else { + LoweredValue::i8(raw.clone()) + }; + if let Some(descriptor) = manifest_ret { + record_native_abi_return(ctx, descriptor, &lowered, name); + } + return Ok(Some(materialize_js_value( + ctx, + lowered, + MaterializationReason::ReturnAbi, + ))); + } else if returns_i16 { + ctx.pending_declares.push((name.clone(), I16, arg_types)); + let raw = ctx.block().call(I16, name, &arg_slices); + let lowered = if matches!(manifest_ret, Some(NativeAbiType::U16)) { + LoweredValue::u16(raw.clone()) + } else { + LoweredValue::i16(raw.clone()) + }; + if let Some(descriptor) = manifest_ret { + record_native_abi_return(ctx, descriptor, &lowered, name); + } + return Ok(Some(materialize_js_value( + ctx, + lowered, + MaterializationReason::ReturnAbi, + ))); } else if returns_i32 { ctx.pending_declares.push((name.clone(), I32, arg_types)); let raw = ctx.block().call(I32, name, &arg_slices); @@ -1545,23 +1719,25 @@ pub fn try_lower_extern_func_call( lowered, MaterializationReason::ReturnAbi, ))); - } else if returns_i64 { - // C function returns i64 in x0 (e.g. `*mut View` - // handles). Declare as I64; the value comes back as a - // raw integer. Convert via `sitofp` so callers see a - // normal JS number; subsequent FFI calls that pass it - // back as an i64 param will truncate via `fptosi`. + } else if returns_i64 || returns_isize { + // Reject native results outside JavaScript's exact integer range. ctx.pending_declares.push((name.clone(), I64, arg_types)); let raw = ctx.block().call(I64, name, &arg_slices); - let lowered = LoweredValue::i64(raw.clone()); + let lowered = if returns_isize { + LoweredValue::isize(raw.clone()) + } else { + LoweredValue::i64(raw.clone()) + }; if let Some(descriptor) = manifest_ret { record_native_abi_return(ctx, descriptor, &lowered, name); + return Ok(Some(materialize_checked_integer_return( + ctx, + &raw, + "js_native_abi_materialize_i64", + descriptor, + ))); } - return Ok(Some(materialize_js_value( - ctx, - lowered, - MaterializationReason::ReturnAbi, - ))); + unreachable!("i64 return routing requires a manifest descriptor"); } else if returns_u32 || returns_buffer_len { ctx.pending_declares.push((name.clone(), I32, arg_types)); let raw = ctx.block().call(I32, name, &arg_slices); @@ -1588,12 +1764,14 @@ pub fn try_lower_extern_func_call( }; if let Some(descriptor) = manifest_ret { record_native_abi_return(ctx, descriptor, &lowered, name); + return Ok(Some(materialize_checked_integer_return( + ctx, + &raw, + "js_native_abi_materialize_u64", + descriptor, + ))); } - return Ok(Some(materialize_js_value( - ctx, - lowered, - MaterializationReason::ReturnAbi, - ))); + unreachable!("u64 return routing requires a manifest descriptor"); } else if returns_f32 { ctx.pending_declares.push((name.clone(), F32, arg_types)); let raw = ctx.block().call(F32, name, &arg_slices); diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index bdea25d439..8cbf5922c4 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -37,15 +37,17 @@ const SHAPE_ID_RANGE_LEN: &str = "1073741824"; // 0x4000_0000 /// The first block proves that the value is a tagged heap pointer or the raw /// object-address form used by internal method ABIs before any dereference. /// The second block reproduces the runtime helper's production contract: the -/// class-prototype invalidation latch is clear, the receiver is a non-forwarded -/// ordinary object without own descriptors, and its exact `(class_id, ShapeId)` -/// pair still matches the compiler-published pair. Any failed proof takes the -/// unchanged dynamic method fallback. +/// all-method escape latch and this method name's invalidation byte are clear, +/// the receiver is a non-forwarded ordinary object without own descriptors, +/// and its exact `(class_id, ShapeId)` pair still matches the +/// compiler-published pair. Any failed proof takes the unchanged dynamic +/// method fallback. fn emit_inline_direct_method_shape_guard( ctx: &mut FnCtx<'_>, recv_box: &str, expected_class_id: &str, expected_shape_id: &str, + method_guard_slot: &str, fast_label: &str, fallback_label: &str, ) { @@ -60,7 +62,15 @@ fn emit_inline_direct_method_shape_guard( let blk = ctx.block(); let invalidated = blk.load_atomic_acquire(I8, "@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED", 1); - let prototype_ok = blk.icmp_eq(I8, &invalidated, "0"); + let all_methods_ok = blk.icmp_eq(I8, &invalidated, "0"); + let method_slot_ptr = blk.gep( + I8, + "@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD", + &[(I64, method_guard_slot)], + ); + let method_invalidated = blk.load_atomic_acquire(I8, &method_slot_ptr, 1); + let method_ok = blk.icmp_eq(I8, &method_invalidated, "0"); + let prototype_ok = blk.and(I1, &all_methods_ok, &method_ok); let recv_bits = blk.bitcast_double_to_i64(recv_box); let recv_handle = blk.and(I64, &recv_bits, crate::nanbox::POINTER_MASK_I64); let tag = blk.lshr(I64, &recv_bits, "48"); @@ -395,6 +405,7 @@ pub(super) fn emit_guarded_direct_method_call( let entry = ctx.strings.entry(key_idx); let bytes_global = format!("@{}", entry.bytes_global); let name_len_str = entry.byte_len.to_string(); + let method_guard_slot_str = (entry.dispatch_hash & 0xffff).to_string(); let dispatch_global = ctx.strings.static_dispatch_global(key_idx); let site_id = if shape_only_guard { None @@ -452,7 +463,11 @@ pub(super) fn emit_guarded_direct_method_call( let cid = ctx.block().call( I32, "js_method_direct_shape_class", - &[(DOUBLE, recv_box), (crate::types::PTR, &shape_slot)], + &[ + (DOUBLE, recv_box), + (crate::types::PTR, &shape_slot), + (I32, &method_guard_slot_str), + ], ); let shape_id = ctx.block().load(I32, &shape_slot); { @@ -486,6 +501,7 @@ pub(super) fn emit_guarded_direct_method_call( recv_box, &expected_class_id_str, &expected_shape_id, + &method_guard_slot_str, &fast_label, &fallback_label, ); @@ -504,6 +520,7 @@ pub(super) fn emit_guarded_direct_method_call( (DOUBLE, recv_box), (I32, &expected_class_id_str), (I32, &expected_shape_id), + (I32, &method_guard_slot_str), ], ) } else { diff --git a/crates/perry-codegen/src/lower_call/native_table/net_events.rs b/crates/perry-codegen/src/lower_call/native_table/net_events.rs index c784f97157..0918b10364 100644 --- a/crates/perry-codegen/src/lower_call/native_table/net_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/net_events.rs @@ -299,7 +299,9 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ method: "connect", class_filter: Some("Socket"), runtime: "js_net_socket_method_connect", - args: &[NA_F64, NA_STR], + // Keep every slot raw so port/host, options, and path overloads reach + // the runtime without callback-to-string coercion. + args: &[NA_F64, NA_F64, NA_F64], ret: NR_VOID, }, NativeModSig { diff --git a/crates/perry-codegen/src/lower_call/omitted_native_params.rs b/crates/perry-codegen/src/lower_call/omitted_native_params.rs index 3566392233..2503f36f27 100644 --- a/crates/perry-codegen/src/lower_call/omitted_native_params.rs +++ b/crates/perry-codegen/src/lower_call/omitted_native_params.rs @@ -31,7 +31,7 @@ use super::extern_func::{ }; use crate::expr::FnCtx; use crate::nanbox::double_literal; -use crate::types::{LlvmType, DOUBLE, F32, I32, I64, PTR}; +use crate::types::{LlvmType, DOUBLE, F32, I16, I32, I64, I8, PTR}; /// Append a sentinel for every manifest param past `passed_args`. No-op when /// the caller passed at least as many args as the manifest declares. @@ -106,10 +106,19 @@ pub(super) fn pad_omitted_native_params( lowered.push("0".to_string()); arg_types.push(I32); } + NativeAbiType::I8 | NativeAbiType::U8 => { + lowered.push("0".to_string()); + arg_types.push(I8); + } + NativeAbiType::I16 | NativeAbiType::U16 => { + lowered.push("0".to_string()); + arg_types.push(I16); + } NativeAbiType::I64 | NativeAbiType::I64String | NativeAbiType::U64 | NativeAbiType::USize + | NativeAbiType::ISize | NativeAbiType::Ptr | NativeAbiType::HandleId | NativeAbiType::Handle(_) diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index b7f903579b..6f0d7a6e40 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -498,6 +498,7 @@ pub(crate) fn try_lower_instance_method_call( let probe_entry = ctx.strings.entry(key_idx_probe); let probe_bytes_global = format!("@{}", probe_entry.bytes_global); let probe_name_len_str = probe_entry.byte_len.to_string(); + let method_guard_slot_str = (probe_entry.dispatch_hash & 0xffff).to_string(); let probe_override_idx = ctx.new_block("idisp.override"); let probe_dispatch_idx = ctx.new_block("idisp.dispatch"); let probe_outer_merge_idx = ctx.new_block("idisp.outer_merge"); @@ -536,7 +537,11 @@ pub(crate) fn try_lower_instance_method_call( let cid = ctx.block().call( I32, "js_method_direct_shape_class", - &[(DOUBLE, &recv_box), (crate::types::PTR, &shape_slot)], + &[ + (DOUBLE, &recv_box), + (crate::types::PTR, &shape_slot), + (I32, &method_guard_slot_str), + ], ); let shape_id = ctx.block().load(I32, &shape_slot); shape_probe_cid = Some(cid.clone()); diff --git a/crates/perry-codegen/src/native_value/materialize.rs b/crates/perry-codegen/src/native_value/materialize.rs index 2006bfd327..3272a06341 100644 --- a/crates/perry-codegen/src/native_value/materialize.rs +++ b/crates/perry-codegen/src/native_value/materialize.rs @@ -376,7 +376,11 @@ pub(crate) fn materialize_js_value_bits( ctx.block().bitcast_double_to_i64(&value) } NativeRep::I64 | NativeRep::ISize => { - let value = ctx.block().sitofp(I64, &lowered.value, DOUBLE); + let value = ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + ); ctx.block().bitcast_double_to_i64(&value) } NativeRep::U8 => { @@ -394,7 +398,11 @@ pub(crate) fn materialize_js_value_bits( ctx.block().bitcast_double_to_i64(&value) } NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - let value = ctx.block().uitofp(I64, &lowered.value, DOUBLE); + let value = ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + ); ctx.block().bitcast_double_to_i64(&value) } NativeRep::BufferLen => { @@ -579,7 +587,11 @@ pub(crate) fn materialize_js_value( ctx.block().sitofp(I32, &widened, DOUBLE) } NativeRep::I32 => ctx.block().sitofp(I32, &lowered.value, DOUBLE), - NativeRep::I64 | NativeRep::ISize => ctx.block().sitofp(I64, &lowered.value, DOUBLE), + NativeRep::I64 | NativeRep::ISize => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + ), NativeRep::U8 => { let widened = ctx.block().zext(I8, &lowered.value, I32); ctx.block().uitofp(I32, &widened, DOUBLE) @@ -589,9 +601,11 @@ pub(crate) fn materialize_js_value( ctx.block().uitofp(I32, &widened, DOUBLE) } NativeRep::U32 => ctx.block().uitofp(I32, &lowered.value, DOUBLE), - NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - ctx.block().uitofp(I64, &lowered.value, DOUBLE) - } + NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + ), NativeRep::BufferLen => ctx.block().uitofp(I32, &lowered.value, DOUBLE), NativeRep::F32 => ctx.block().fpext(F32, &lowered.value, DOUBLE), NativeRep::StringRef => nanbox_string_ref_boxed(ctx, &lowered.value), @@ -655,7 +669,11 @@ pub(crate) fn materialize_js_value_without_record( ctx.block().sitofp(I32, &widened, DOUBLE) } NativeRep::I32 => ctx.block().sitofp(I32, &lowered.value, DOUBLE), - NativeRep::I64 | NativeRep::ISize => ctx.block().sitofp(I64, &lowered.value, DOUBLE), + NativeRep::I64 | NativeRep::ISize => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_i64", + &[(I64, &lowered.value)], + ), NativeRep::U8 => { let widened = ctx.block().zext(I8, &lowered.value, I32); ctx.block().uitofp(I32, &widened, DOUBLE) @@ -665,9 +683,11 @@ pub(crate) fn materialize_js_value_without_record( ctx.block().uitofp(I32, &widened, DOUBLE) } NativeRep::U32 => ctx.block().uitofp(I32, &lowered.value, DOUBLE), - NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => { - ctx.block().uitofp(I64, &lowered.value, DOUBLE) - } + NativeRep::U64 | NativeRep::USize | NativeRep::HandleId => ctx.block().call( + DOUBLE, + "js_native_abi_materialize_u64", + &[(I64, &lowered.value)], + ), NativeRep::BufferLen => ctx.block().uitofp(I32, &lowered.value, DOUBLE), NativeRep::F32 => ctx.block().fpext(F32, &lowered.value, DOUBLE), NativeRep::BufferView(_) diff --git a/crates/perry-codegen/src/native_value/pod.rs b/crates/perry-codegen/src/native_value/pod.rs index 7e34f47369..aababc15b1 100644 --- a/crates/perry-codegen/src/native_value/pod.rs +++ b/crates/perry-codegen/src/native_value/pod.rs @@ -89,7 +89,14 @@ pub(crate) fn layout_for_pod_view_type( pub(crate) fn collect_pod_init_fields( ctx: &FnCtx<'_>, init: &Expr, + layout: &PodLayoutManifest, ) -> Result { + let mut fields = Vec::with_capacity(layout.fields.len()); + flatten_pod_init_fields(ctx, init, layout, &[], &mut fields)?; + Ok(PodInitFields { fields }) +} + +fn direct_pod_init_fields(ctx: &FnCtx<'_>, init: &Expr) -> Result, String> { match init { Expr::Object(props) => { let mut seen = std::collections::HashSet::new(); @@ -100,7 +107,7 @@ pub(crate) fn collect_pod_init_fields( } fields.push((name.clone(), value.clone())); } - Ok(PodInitFields { fields }) + Ok(fields) } Expr::New { class_name, args, .. @@ -126,13 +133,42 @@ pub(crate) fn collect_pod_init_fields( } fields.push((field.name.clone(), arg.clone())); } - Ok(PodInitFields { fields }) + Ok(fields) } Expr::ObjectSpread { .. } => Err("spread_property".to_string()), _ => Err("unsupported_initializer".to_string()), } } +fn flatten_pod_init_fields( + ctx: &FnCtx<'_>, + init: &Expr, + layout: &PodLayoutManifest, + prefix: &[String], + output: &mut Vec<(String, Expr)>, +) -> Result<(), String> { + for (name, value) in direct_pod_init_fields(ctx, init)? { + let mut path = prefix.to_vec(); + path.push(name); + let flattened = path.join("."); + if layout.fields.iter().any(|field| field.path == path) { + output.push((flattened, value)); + continue; + } + if layout + .fields + .iter() + .any(|field| field.path.len() > path.len() && field.path.starts_with(path.as_slice())) + { + flatten_pod_init_fields(ctx, &value, layout, &path, output) + .map_err(|reason| format!("nested_field:{flattened}:{reason}"))?; + continue; + } + output.push((flattened, value)); + } + Ok(()) +} + pub(crate) fn validate_exact_init( layout: &PodLayoutManifest, init_fields: &PodInitFields, @@ -337,11 +373,16 @@ pub(crate) fn expected_rep_for_native_rep(rep: &NativeRep) -> Option Option { Some(match ty { + NativeAbiType::I8 => NativeRep::I8, + NativeAbiType::I16 => NativeRep::I16, NativeAbiType::I32 => NativeRep::I32, NativeAbiType::I64 => NativeRep::I64, + NativeAbiType::U8 => NativeRep::U8, + NativeAbiType::U16 => NativeRep::U16, NativeAbiType::U32 => NativeRep::U32, NativeAbiType::U64 => NativeRep::U64, NativeAbiType::USize => NativeRep::USize, + NativeAbiType::ISize => NativeRep::ISize, NativeAbiType::F32 => NativeRep::F32, NativeAbiType::F64 => NativeRep::F64, NativeAbiType::BufferLen => NativeRep::BufferLen, diff --git a/crates/perry-codegen/src/native_value/verify/abi.rs b/crates/perry-codegen/src/native_value/verify/abi.rs index 07c2612505..b466dda876 100644 --- a/crates/perry-codegen/src/native_value/verify/abi.rs +++ b/crates/perry-codegen/src/native_value/verify/abi.rs @@ -158,11 +158,16 @@ pub(crate) fn validate_native_abi_type_record( ) } "bool" => matches!(&record.native_rep, NativeRep::I1 | NativeRep::I32), + "i8" => matches!(&record.native_rep, NativeRep::I8), + "i16" => matches!(&record.native_rep, NativeRep::I16), "i32" => matches!(&record.native_rep, NativeRep::I32), "i64" => matches!(&record.native_rep, NativeRep::I64), + "u8" => matches!(&record.native_rep, NativeRep::U8), + "u16" => matches!(&record.native_rep, NativeRep::U16), "u32" => matches!(&record.native_rep, NativeRep::U32), "u64" => matches!(&record.native_rep, NativeRep::U64), "usize" => matches!(&record.native_rep, NativeRep::USize), + "isize" => matches!(&record.native_rep, NativeRep::ISize), "f32" => matches!(&record.native_rep, NativeRep::F32), "f64" => matches!(&record.native_rep, NativeRep::F64 | NativeRep::JsValue), "buffer_len" => matches!(&record.native_rep, NativeRep::BufferLen), @@ -256,11 +261,16 @@ pub(crate) fn valid_runtime_guard_helper(kind: &str, helper: &str) -> bool { "string" => helper == "js_native_abi_check_string_ptr", "json" => helper == "js_json_stringify", "bool" => helper == "js_is_truthy", + "i8" => helper == "js_native_abi_check_i8", + "i16" => helper == "js_native_abi_check_i16", "i32" => helper == "js_native_abi_check_i32", "i64" | "i64_str" => helper == "js_native_abi_check_i64", + "u8" => helper == "js_native_abi_check_u8", + "u16" => helper == "js_native_abi_check_u16", "u32" | "buffer_len" => helper == "js_native_abi_check_u32", "u64" => helper == "js_native_abi_check_u64", "usize" => helper == "js_native_abi_check_usize", + "isize" => helper == "js_native_abi_check_isize", "f32" => helper == "js_native_abi_check_f32", "f64" => helper == "js_native_abi_check_f64", "ptr" => helper == "js_native_abi_check_ptr", diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs index 76338f75f7..a0781361f5 100644 --- a/crates/perry-codegen/src/runtime_decls/mod.rs +++ b/crates/perry-codegen/src/runtime_decls/mod.rs @@ -144,11 +144,18 @@ pub fn declare_phase1(module: &mut LlModule) { module.declare_function("js_typed_string_arg_to_raw", I64, &[DOUBLE]); module.declare_function("js_param_type_guard", I32, &[DOUBLE, PTR, I32]); module.declare_function("js_native_abi_check_f32", F32, &[DOUBLE]); + module.declare_function("js_native_abi_check_i8", I8, &[DOUBLE]); + module.declare_function("js_native_abi_check_i16", I16, &[DOUBLE]); module.declare_function("js_native_abi_check_i32", I32, &[DOUBLE]); module.declare_function("js_native_abi_check_i64", I64, &[DOUBLE]); + module.declare_function("js_native_abi_check_u8", I8, &[DOUBLE]); + module.declare_function("js_native_abi_check_u16", I16, &[DOUBLE]); module.declare_function("js_native_abi_check_u32", I32, &[DOUBLE]); module.declare_function("js_native_abi_check_u64", I64, &[DOUBLE]); module.declare_function("js_native_abi_check_usize", I64, &[DOUBLE]); + module.declare_function("js_native_abi_check_isize", I64, &[DOUBLE]); + module.declare_function("js_native_abi_materialize_i64", DOUBLE, &[I64]); + module.declare_function("js_native_abi_materialize_u64", DOUBLE, &[I64]); module.declare_function("js_native_abi_check_string_ptr", I64, &[DOUBLE]); module.declare_function("js_native_abi_check_ptr", I64, &[DOUBLE]); module.declare_function("js_native_abi_check_buffer_data_ptr", PTR, &[DOUBLE]); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 0faf427979..707e24780d 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -33,6 +33,12 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // Direct-method lowering reads it with acquire ordering before touching a // receiver header; prototype mutation stores 1 with release ordering. module.add_external_global("PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED", I8); + // Per-method sticky invalidation table indexed by low FNV-1a bits. A + // collision is conservative: it only disables another direct guard. + module.add_external_global( + "PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD", + "[65536 x i8]", + ); // #7834/#7873: process-global count of threads with per-object records. // `0` proves both per-object side tables are empty everywhere, so a // construction site can skip `js_gc_forget_object_layout` outright. @@ -214,8 +220,12 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { I32, &[I64, DOUBLE, I32, I32, PTR, I64, PTR], ); - module.declare_function("js_method_direct_shape_guard", I32, &[DOUBLE, I32, I32]); - module.declare_function("js_method_direct_shape_class", I32, &[DOUBLE, PTR]); + module.declare_function( + "js_method_direct_shape_guard", + I32, + &[DOUBLE, I32, I32, I32], + ); + module.declare_function("js_method_direct_shape_class", I32, &[DOUBLE, PTR, I32]); module.declare_function( "js_typed_feedback_closure_direct_call_guard", I32, diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 706dde5c59..4a2c62ad9f 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1,5 +1,3 @@ -//! `Stmt::Let` lowering — large arm extracted from the dispatcher. - use super::*; use super::let_buffer_views::{math_min_length_buffer_ids, register_noalias_buffer_view}; @@ -20,13 +18,8 @@ use crate::native_value::{ use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; -/// #5271: does `init` provably evaluate to a plain object literal? Two -/// shapes reach codegen: a data-only literal stays `Expr::Object`, while a -/// literal carrying methods/getters lowers to an immediately-invoked -/// object-building closure whose sole param is named `__perry_obj_iife` -/// and whose single argument is the seed `Object(..)`. Recognizing both -/// lets `o.trim()` / `internals.trim(v, s)` resolve to the receiver's own -/// member rather than `String.prototype.trim`. +/// #5271: recognize both data-only object literals and method/getter IIFEs so +/// own members win over built-in prototype methods during lowering. fn is_object_literal_init(init: &perry_hir::Expr) -> bool { use perry_hir::Expr; match init { @@ -443,6 +436,25 @@ pub(crate) fn lower_let( } if let Some(init_expr) = init { + let copied = match init_expr { + perry_hir::Expr::LocalGet(source_id) if !ctx.boxed_vars.contains(&id) => { + crate::expr::copy_pod_local(ctx, id, *source_id)? + } + _ => None, + }; + if let Some(copied) = copied { + ctx.local_types.insert(id, refined_ty.clone()); + ctx.locals.insert(id, copied.materialized_slot.clone()); + ctx.pod_records.insert(id, copied); + if ctx.module_globals.contains_key(&id) { + let _ = crate::expr::materialize_pod_local( + ctx, + id, + MaterializationReason::PodMaterialization, + )?; + } + return Ok(()); + } match crate::native_value::layout_decision_for_type(ctx, &refined_ty) { PodLayoutDecision::Layout(_) if ctx.boxed_vars.contains(&id) && !ctx.module_globals.contains_key(&id) => @@ -454,12 +466,11 @@ pub(crate) fn lower_let( ); } PodLayoutDecision::Layout(layout) => { - match crate::native_value::collect_pod_init_fields(ctx, init_expr).and_then( - |fields| { + match crate::native_value::collect_pod_init_fields(ctx, init_expr, &layout) + .and_then(|fields| { crate::native_value::validate_exact_init(&layout, &fields)?; Ok(fields) - }, - ) { + }) { Ok(init_fields) => { let data_slot = ctx .func @@ -535,9 +546,8 @@ pub(crate) fn lower_let( } } - // Keep a non-escaping uppercase result virtual when every consumer is a - // fused string operation. Store the original boxed receiver now so later - // writes to its source local cannot change the captured value. + // Keep non-escaping uppercase results virtual for fused consumers while + // snapshotting the receiver against later source-local writes. if let Some(perry_hir::Expr::Call { callee, args, .. }) = init { if ctx.fusible_uppercase_locals.contains(&id) && args.is_empty() diff --git a/crates/perry-codegen/tests/native_proof_regressions/native_library.rs b/crates/perry-codegen/tests/native_proof_regressions/native_library.rs index c572e9fa19..4fcfe1fa0f 100644 --- a/crates/perry-codegen/tests/native_proof_regressions/native_library.rs +++ b/crates/perry-codegen/tests/native_proof_regressions/native_library.rs @@ -1,5 +1,74 @@ use super::*; +#[test] +fn native_library_exact_width_scalars_use_distinct_guards_and_c_abi_slots() { + let opts = native_library_opts(vec![ + ( + "native_exact_args", + vec!["i8", "i16", "u8", "u16", "isize"], + "void", + ), + ("native_ret_i8", vec![], "i8"), + ("native_ret_i16", vec![], "i16"), + ("native_ret_u8", vec![], "u8"), + ("native_ret_u16", vec![], "u16"), + ("native_ret_isize", vec![], "isize"), + ]); + let module = module( + "native_library_exact_widths.ts", + vec![ + Stmt::Expr(extern_call( + "native_exact_args", + vec![int(-8), int(-16), int(8), int(16), int(-64)], + Type::Void, + )), + Stmt::Expr(extern_call("native_ret_i8", vec![], Type::Number)), + Stmt::Expr(extern_call("native_ret_i16", vec![], Type::Number)), + Stmt::Expr(extern_call("native_ret_u8", vec![], Type::Number)), + Stmt::Expr(extern_call("native_ret_u16", vec![], Type::Number)), + Stmt::Return(Some(extern_call("native_ret_isize", vec![], Type::Number))), + ], + ); + let ir = String::from_utf8(compile_module(&module, opts.clone()).unwrap()).unwrap(); + for guard in [ + "js_native_abi_check_i8", + "js_native_abi_check_i16", + "js_native_abi_check_u8", + "js_native_abi_check_u16", + "js_native_abi_check_isize", + ] { + assert!(ir.contains(guard), "missing guard {guard}:\n{ir}"); + } + assert!( + ir.contains("declare void @native_exact_args(i8, i16, i8, i16, i64)") + && ir.contains("declare i8 @native_ret_i8()") + && ir.contains("declare i16 @native_ret_i16()") + && ir.contains("declare i8 @native_ret_u8()") + && ir.contains("declare i16 @native_ret_u16()") + && ir.contains("declare i64 @native_ret_isize()"), + "exact-width descriptors must preserve their C ABI slots:\n{ir}" + ); + + let artifact = compile_artifact_json_for_module_with_opts(module, opts); + let records = artifact["records"].as_array().unwrap(); + for (kind, rep, helper) in [ + ("i8", "i8", "js_native_abi_check_i8"), + ("i16", "i16", "js_native_abi_check_i16"), + ("u8", "u8", "js_native_abi_check_u8"), + ("u16", "u16", "js_native_abi_check_u16"), + ("isize", "isize", "js_native_abi_check_isize"), + ] { + assert!( + records.iter().any(|record| { + record["native_abi_type"]["canonical_kind"] == kind + && record["native_rep_name"] == rep + && record["native_abi_type"]["runtime_guard"]["helper"] == helper + }), + "missing exact-width proof {kind}/{rep}/{helper}:\n{artifact:#}" + ); + } +} + #[test] fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() { let opts = native_library_opts(vec![ @@ -61,6 +130,8 @@ fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() && ir.contains("declare i32 @native_ret_buffer_len()") && ir.contains("declare i64 @native_ret_handle()") && ir.contains("declare i64 @native_ret_promise()") + && ir.contains("call double @js_native_abi_materialize_i64") + && ir.contains("call double @js_native_abi_materialize_u64") && ir.contains("call double @js_native_handle_new_borrowed"), "expected lowercase manifest return kinds to drive LLVM declarations:\n{ir}" ); @@ -121,13 +192,6 @@ fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() ); } for (consumer, from_rep, op, lossy) in [ - ("materialize_js_value", "u64", "unsigned_int_to_float", true), - ( - "materialize_js_value", - "usize", - "unsigned_int_to_float", - true, - ), ("materialize_js_value", "f32", "float_extend", false), ( "materialize_native_handle_runtime", @@ -154,6 +218,24 @@ fn native_library_manifest_lowercase_abi_returns_emit_signatures_and_artifacts() "expected native-library transition {from_rep}->{op}:\n{artifact:#}" ); } + for (descriptor, guard) in [ + ("i64", "js_native_abi_materialize_i64"), + ("u64", "js_native_abi_materialize_u64"), + ("usize", "js_native_abi_materialize_u64"), + ] { + assert!( + records.iter().any(|record| { + record["consumer"] == "native_library.checked_integer_return" + && record["notes"].as_array().is_some_and(|notes| { + notes + .iter() + .any(|note| note == &format!("descriptor={descriptor}")) + && notes.iter().any(|note| note == &format!("guard={guard}")) + }) + }), + "missing exact safe-integer return guard for {descriptor}:\n{artifact:#}" + ); + } } #[test] diff --git a/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs b/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs index 556cfb4d08..916168727e 100644 --- a/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs +++ b/crates/perry-codegen/tests/native_proof_regressions/pod_manifest.rs @@ -1,5 +1,109 @@ use super::*; +#[test] +fn pod_i64_and_u64_materialization_uses_safe_integer_guards() { + let packet_ty = pod_type(&[ + ("signed", Type::Named("PerryI64".to_string())), + ("unsigned", Type::Named("PerryU64".to_string())), + ]); + let module = module( + "pod_safe_integer_materialization.ts", + vec![ + pod_let( + 1, + "packet", + packet_ty, + vec![("signed", int(-7)), ("unsigned", int(9))], + ), + Stmt::Return(Some(local(1))), + ], + ); + + let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); + assert!( + ir.contains("call double @js_native_abi_materialize_i64"), + "signed POD fields must reject imprecise managed materialization:\n{ir}" + ); + assert!( + ir.contains("call double @js_native_abi_materialize_u64"), + "unsigned POD fields must reject imprecise managed materialization:\n{ir}" + ); +} + +#[test] +fn nested_pod_initializers_and_local_assignments_preserve_value_semantics() { + let nested_ty = pod_type(&[ + ("code", Type::Named("PerryU16".to_string())), + ("delta", Type::Named("PerryI8".to_string())), + ]); + let packet_ty = pod_type(&[ + ("tag", Type::Named("PerryU8".to_string())), + ("nested", nested_ty), + ]); + let module = module( + "nested_pod_copy.ts", + vec![ + pod_let( + 1, + "original", + packet_ty.clone(), + vec![ + ("tag", int(7)), + ( + "nested", + Expr::Object(vec![ + ("code".to_string(), int(513)), + ("delta".to_string(), int(-8)), + ]), + ), + ], + ), + Stmt::Let { + id: 2, + name: "copy".to_string(), + ty: packet_ty, + mutable: true, + init: Some(local(1)), + }, + Stmt::Return(Some(local(1))), + ], + ); + + let artifact = compile_artifact_json_for_module(module); + let records = artifact["records"].as_array().unwrap(); + assert!( + records.iter().any(|record| { + record["expr_kind"] == "PodRecordLiteralInit" + && record["consumer"] == "pod_record_stack_alloc" + && record["pod_layout"]["fields"] + .as_array() + .is_some_and(|fields| fields.len() == 3) + }), + "nested POD literal should flatten into one C layout:\n{artifact:#}" + ); + assert!( + records.iter().any(|record| { + record["expr_kind"] == "PodRecordCopyInit" + && record["consumer"] == "pod_record_value_copy" + && record["notes"].as_array().is_some_and(|notes| { + notes.iter().any(|note| note == "assignment_semantics=copy") + }) + }), + "POD local assignment must snapshot into independent storage:\n{artifact:#}" + ); + assert!( + records.iter().any(|record| { + record["consumer"] == "pod_record_materialized_value_copy" + && record["notes"].as_array().is_some_and(|notes| { + notes + .iter() + .any(|note| note == "value_semantics=copy_at_managed_boundary") + }) + }), + "a managed POD boundary must receive a copied object:\n{artifact:#}" + ); +} + #[test] fn checked_native_scalar_conversions_keep_dynamic_pod_initializers_native() { let packet_ty = pod_type(&[ diff --git a/crates/perry-ext-net/src/dispatch.rs b/crates/perry-ext-net/src/dispatch.rs index 8909755f15..76afc8fbe8 100644 --- a/crates/perry-ext-net/src/dispatch.rs +++ b/crates/perry-ext-net/src/dispatch.rs @@ -232,9 +232,11 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option crate::js_net_socket_on(handle, unbox_to_i64(args[0]), unbox_to_i64(args[1])); nanbox_handle(handle) } - "connect" if args.len() >= 2 => { - crate::js_net_socket_method_connect(handle, args[0], unbox_to_i64(args[1])); - undefined() + "connect" if !args.is_empty() => { + let arg2 = args.get(1).copied().unwrap_or_else(undefined); + let arg3 = args.get(2).copied().unwrap_or_else(undefined); + crate::js_ext_net_socket_method_connect(handle, args[0], arg2, arg3); + nanbox_handle(handle) } "upgradeToTLS" if !args.is_empty() => { let verify = args.get(1).copied().unwrap_or(1.0); diff --git a/crates/perry-ext-net/src/ipc.rs b/crates/perry-ext-net/src/ipc.rs new file mode 100644 index 0000000000..a13eb49a53 --- /dev/null +++ b/crates/perry-ext-net/src/ipc.rs @@ -0,0 +1,407 @@ +//! Local IPC transport for `node:net` path overloads. +//! +//! Node maps `server.listen(path)` and `net.connect({ path })` to named pipes +//! on Windows and Unix-domain sockets on Unix. The streams join the same +//! SocketState command/event loop as TCP, so data, end, error, close, and +//! server connection events keep one implementation. + +use std::io; + +#[cfg(windows)] +use std::time::Duration; + +use tokio::sync::{mpsc, oneshot}; + +use crate::{ + dispatch, ensure_gc_scanner_registered, mark_closed, next_id, next_id_or_throw, push_event, + run_socket_task, server_state, statics, PendingNetEvent, SocketCommand, SocketState, + TlsSocketMetadata, Transport, +}; + +#[cfg(unix)] +use tokio::net::{UnixListener, UnixStream}; + +#[cfg(windows)] +use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeServer, ServerOptions}; + +fn allocate_socket() -> (i64, mpsc::UnboundedReceiver) { + ensure_gc_scanner_registered(); + dispatch::ensure_runtime_dispatch_registered(); + let id = next_id_or_throw(); + let (tx, rx) = mpsc::unbounded_channel::(); + statics::sockets().lock().unwrap().insert( + id, + SocketState { + cmd_tx: tx, + pending_rx: None, + is_open: false, + refed: true, + local_addr: None, + raw: None, + destroyed: false, + bytes_read: 0, + bytes_written: 0, + timeout: None, + type_of_service: 0, + server_id: None, + server_connection_active: false, + tls: TlsSocketMetadata::default(), + }, + ); + statics::listeners() + .lock() + .unwrap() + .insert(id, Default::default()); + (id, rx) +} + +/// Read a JS string without coercing closures or option objects through a +/// StringHeader layout. +pub(crate) unsafe fn string_value(value: f64) -> Option { + let value = perry_ffi::JsValue::from_bits(value.to_bits()); + value + .is_string() + .then(|| crate::string_from_header_i64(value.as_string_ptr() as i64))? +} + +pub(crate) fn register_connect_cb(handle: i64, cb_f64: f64) { + if handle == 0 || !crate::is_nanboxed_pointer(cb_f64) { + return; + } + let cb_ptr = unsafe { crate::unbox_pointer(cb_f64) } as i64; + if cb_ptr == 0 { + return; + } + statics::listeners() + .lock() + .unwrap() + .entry(handle) + .or_default() + .entry("connect".to_string()) + .or_default() + .push(cb_ptr); +} + +/// Publish an accepted TCP or IPC stream as a normal net.Socket and start its +/// shared command/read loop. Admission accounting has already reserved one +/// pending connection before this helper is called. +pub(crate) fn register_accepted_transport( + server_id: i64, + transport: Transport, + local_addr: Option, +) { + let socket_id = next_id(); + if socket_id == perry_ffi::INVALID_HANDLE { + server_state::cancel_pending_connection(server_id); + return; + } + let (tx, rx) = mpsc::unbounded_channel::(); + statics::sockets().lock().unwrap().insert( + socket_id, + SocketState { + cmd_tx: tx, + pending_rx: None, + is_open: true, + refed: true, + local_addr, + raw: None, + destroyed: false, + bytes_read: 0, + bytes_written: 0, + timeout: None, + type_of_service: 0, + server_id: Some(server_id), + server_connection_active: false, + tls: TlsSocketMetadata::default(), + }, + ); + statics::listeners() + .lock() + .unwrap() + .insert(socket_id, Default::default()); + push_event(PendingNetEvent::ServerConnection( + server_id, socket_id, false, + )); + tokio::spawn(async move { + let mut rx = rx; + run_socket_task(socket_id, transport, &mut rx).await; + }); +} + +pub(crate) fn spawn_socket(path: String) -> i64 { + let (id, rx) = allocate_socket(); + spawn_connect(id, path, rx); + id +} + +pub(crate) fn connect_existing(handle: i64, path: String) { + let rx = { + let mut sockets = statics::sockets().lock().unwrap(); + match sockets + .get_mut(&handle) + .and_then(|socket| socket.pending_rx.take()) + { + Some(rx) => rx, + None => { + push_event(PendingNetEvent::Error( + handle, + "socket already connected (or unknown handle)".to_string(), + )); + return; + } + } + }; + spawn_connect(handle, path, rx); +} + +fn spawn_connect(id: i64, path: String, mut rx: mpsc::UnboundedReceiver) { + let local_server = server_state::begin_local_path_connect(&path); + crate::spawn_socket_runner(move || { + Box::pin(async move { + let stream = match connect_path(&path).await { + Ok(stream) => stream, + Err(error) => { + server_state::cancel_local_connect(local_server); + push_event(PendingNetEvent::Error( + id, + format!("connect {path}: {error}"), + )); + push_event(PendingNetEvent::Close(id)); + mark_closed(id); + return; + } + }; + + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&id) { + socket.is_open = true; + } + tokio::task::yield_now().await; + push_event(PendingNetEvent::Connect(id, local_server)); + run_socket_task(id, Transport::Ipc(stream), &mut rx).await; + }) + }); +} + +pub(crate) fn spawn_listener(server_id: i64, path: String, shutdown_rx: oneshot::Receiver<()>) { + perry_ffi::spawn_async(async move { + if let Err(error) = run_listener(server_id, path.clone(), shutdown_rx).await { + push_event(PendingNetEvent::ServerError( + server_id, + format!("bind {path}: {error}"), + )); + } + push_event(PendingNetEvent::ServerClose(server_id)); + if let Ok(mut servers) = statics::servers().lock() { + if let Some(server) = servers.get_mut(&server_id) { + server.listening = false; + } + } + }); +} + +#[cfg(unix)] +async fn connect_path(path: &str) -> io::Result> { + UnixStream::connect(path) + .await + .map(|stream| Box::new(stream) as Box) +} + +#[cfg(unix)] +async fn run_listener( + server_id: i64, + path: String, + mut shutdown_rx: oneshot::Receiver<()>, +) -> io::Result<()> { + let listener = UnixListener::bind(&path)?; + push_event(PendingNetEvent::ServerListening(server_id)); + + loop { + tokio::select! { + accepted = listener.accept() => match accepted { + Ok((stream, _)) => { + if let Some(info) = server_state::should_drop_ipc_connection(server_id) { + push_event(PendingNetEvent::ServerDrop(server_id, info)); + } else { + register_accepted_transport( + server_id, + Transport::Ipc(Box::new(stream)), + None, + ); + } + } + Err(error) => { + push_event(PendingNetEvent::ServerError( + server_id, + format!("accept: {error}"), + )); + } + }, + _ = &mut shutdown_rx => break, + } + } + + drop(listener); + // Tokio deliberately leaves filesystem socket nodes behind. Only unlink + // after our listener has closed; bind failures never remove someone else's + // endpoint. + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(windows)] +async fn connect_path(path: &str) -> io::Result> { + loop { + match ClientOptions::new().open(path) { + Ok(stream) => { + return Ok(Box::new(stream) as Box); + } + // ERROR_PIPE_BUSY: all instances are serving clients. Match + // Node/libuv's wait-and-retry behavior rather than reporting a + // transient connector failure. + Err(error) if error.raw_os_error() == Some(231) => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => return Err(error), + } + } +} + +#[cfg(windows)] +fn create_pipe_server(path: &str, first: bool) -> io::Result { + ServerOptions::new().first_pipe_instance(first).create(path) +} + +#[cfg(windows)] +async fn run_listener( + server_id: i64, + path: String, + mut shutdown_rx: oneshot::Receiver<()>, +) -> io::Result<()> { + let mut listener = create_pipe_server(&path, true)?; + push_event(PendingNetEvent::ServerListening(server_id)); + + loop { + tokio::select! { + connected = listener.connect() => { + connected?; + let stream = listener; + // A Windows named-pipe instance accepts exactly one client. + // Create the next instance before publishing the accepted one + // so concurrent connectors do not observe a needless gap. + listener = create_pipe_server(&path, false)?; + if let Some(info) = server_state::should_drop_ipc_connection(server_id) { + push_event(PendingNetEvent::ServerDrop(server_id, info)); + drop(stream); + } else { + register_accepted_transport( + server_id, + Transport::Ipc(Box::new(stream)), + None, + ); + } + } + _ = &mut shutdown_rx => break, + } + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +async fn connect_path(_path: &str) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "local IPC sockets are unsupported on this platform", + )) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU64, Ordering}; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + static NEXT_TEST_PIPE: AtomicU64 = AtomicU64::new(1); + + fn unique_name() -> String { + let suffix = NEXT_TEST_PIPE.fetch_add(1, Ordering::Relaxed); + #[cfg(windows)] + return format!(r"\\.\pipe\perry-ext-net-{}-{suffix}", std::process::id()); + #[cfg(unix)] + return std::env::temp_dir() + .join(format!( + "perry-ext-net-{}-{suffix}.sock", + std::process::id() + )) + .to_string_lossy() + .into_owned(); + #[cfg(not(any(unix, windows)))] + return String::new(); + } + + #[test] + fn nanboxed_string_is_recognized_as_an_ipc_path() { + let path = unique_name(); + let header = perry_ffi::alloc_string(&path).as_raw(); + let value = f64::from_bits(perry_ffi::nanbox_string_bits(header)); + assert_eq!(unsafe { super::string_value(value) }, Some(path)); + } + + #[cfg(windows)] + #[tokio::test] + async fn named_pipe_stream_round_trip() { + let path = unique_name(); + let mut server = super::create_pipe_server(&path, true).unwrap(); + let connect_path = path.clone(); + let client = tokio::spawn(async move { super::connect_path(&connect_path).await }); + server.connect().await.unwrap(); + let mut client = super::Transport::Ipc(client.await.unwrap().unwrap()); + + client.write_all(b"ping").await.unwrap(); + let mut request = [0; 4]; + server.read_exact(&mut request).await.unwrap(); + assert_eq!(&request, b"ping"); + + server.write_all(b"pong").await.unwrap(); + let mut response = [0; 4]; + client.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"pong"); + } + + #[cfg(unix)] + #[tokio::test] + async fn unix_socket_stream_round_trip() { + let path = unique_name(); + let listener = super::UnixListener::bind(&path).unwrap(); + let connect_path = path.clone(); + let client = tokio::spawn(async move { super::connect_path(&connect_path).await }); + let (mut server, _) = listener.accept().await.unwrap(); + let mut client = super::Transport::Ipc(client.await.unwrap().unwrap()); + + client.write_all(b"ping").await.unwrap(); + let mut request = [0; 4]; + server.read_exact(&mut request).await.unwrap(); + assert_eq!(&request, b"ping"); + + server.write_all(b"pong").await.unwrap(); + let mut response = [0; 4]; + client.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"pong"); + drop(listener); + std::fs::remove_file(path).unwrap(); + } +} + +#[cfg(not(any(unix, windows)))] +async fn run_listener( + _server_id: i64, + _path: String, + _shutdown_rx: oneshot::Receiver<()>, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "local IPC sockets are unsupported on this platform", + )) +} diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 73372b5626..54ae80a332 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -71,6 +71,7 @@ mod handle_ids; pub(crate) use handle_ids::{next_id, next_id_or_throw}; mod dispatch; mod dispatch_custody; +mod ipc; mod socket_emit; pub use socket_emit::{ js_ext_net_register_http_agent_socket_event_hook, js_ext_net_set_http_agent_phase, @@ -226,6 +227,9 @@ pub(crate) struct ServerState { pub shutdown_tx: Option>, pub bound_port: u16, pub bound_host: String, + /// Named-pipe / Unix-domain-socket path for an IPC listener. TCP servers + /// leave this unset and use `bound_host` + `bound_port`. + pub bound_path: Option, pub listening: bool, pub active_connections: usize, pub pending_connections: usize, @@ -489,7 +493,7 @@ where /// `net.createConnection(...)` / `net.connect(...)` — returns a handle /// immediately; connection happens in the background and emits -/// `'connect'` or `'error'`. Supports both Node overloads: +/// `'connect'` or `'error'`. Supports Node's TCP and IPC overloads: /// /// - Positional: `net.connect(port, host, cb?)`. `arg1_f64` is the /// port as a regular f64 number, `arg2_f64` carries the host as a @@ -499,6 +503,7 @@ where /// `port`; `arg2_f64` is the optional `connectListener`. In this /// form `arg3_f64` is unused (the dispatch table pads it with /// `undefined`). Issue #770. +/// - IPC: `net.connect(path, cb?)` or `net.connect({ path }, cb?)`. /// /// The `connectListener` (whichever slot it ends up in) is /// auto-registered as a `'connect'` listener on the new socket @@ -527,27 +532,21 @@ pub unsafe extern "C" fn js_ext_net_socket_connect( #[no_mangle] pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg3_f64: f64) -> i64 { - /// Register `cb_f64` as a `'connect'` listener on `handle` if it - /// carries a real closure pointer. No-op otherwise. - fn register_connect_cb(handle: i64, cb_f64: f64) { - if handle == 0 || !is_nanboxed_pointer(cb_f64) { - return; - } - let cb_ptr = unsafe { unbox_pointer(cb_f64) } as i64; - if cb_ptr == 0 { - return; - } - let mut listeners = statics::listeners().lock().unwrap(); - listeners - .entry(handle) - .or_default() - .entry("connect".to_string()) - .or_default() - .push(cb_ptr); + // Path overload: `net.connect(path[, cb])`. + if let Some(path) = ipc::string_value(arg1_f64) { + let handle = ipc::spawn_socket(path); + ipc::register_connect_cb(handle, arg2_f64); + return handle; } if is_nanboxed_pointer(arg1_f64) { - // Options-object overload: extract host/port from the object. + // Options-object overload. A `path` selects local IPC before the TCP + // host/port fields are considered, matching Node's normalization. + if let Some(path) = get_object_string_field(arg1_f64, "path") { + let handle = ipc::spawn_socket(path); + ipc::register_connect_cb(handle, arg2_f64); + return handle; + } let host = match get_object_string_field(arg1_f64, "host") .or_else(|| get_object_string_field(arg1_f64, "hostname")) { @@ -564,7 +563,7 @@ pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg }; let handle = spawn_socket_task(host, port, /* direct_tls: */ None); // connectListener lives in arg2 for the options form. - register_connect_cb(handle, arg2_f64); + ipc::register_connect_cb(handle, arg2_f64); return handle; } // Positional overload: arg1 is the port number, arg2 is the host @@ -588,7 +587,7 @@ pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg js_net_validate_connect_port(arg1_f64); let port = arg1_f64 as u16; let handle = spawn_socket_task(host, port, /* direct_tls: */ None); - register_connect_cb(handle, listener_f64); + ipc::register_connect_cb(handle, listener_f64); handle } @@ -656,6 +655,7 @@ pub unsafe extern "C" fn js_net_create_server( shutdown_tx: None, bound_port: 0, bound_host: String::new(), + bound_path: None, listening: false, active_connections: 0, pending_connections: 0, @@ -679,9 +679,9 @@ pub unsafe extern "C" fn js_net_create_server( // ─── FFI: net.Server.listen / .close / .address / .on ──────────────────────── -/// `server.listen(port, callback?)` — bind a tokio `TcpListener` on -/// `0.0.0.0:port` and spawn an accept loop on the shared multi-thread -/// runtime. The `callback` (a NaN-boxed closure pointer in the codegen's +/// `server.listen(port | path, callback?)` — bind TCP, a Windows named pipe, +/// or a Unix-domain socket and spawn an accept loop on the shared runtime. +/// The `callback` (a NaN-boxed closure pointer in the codegen's /// NA_PTR slot, raw i64 here after unboxing in lower_call.rs) is /// registered as a one-shot `'listening'` listener; when the bind /// resolves, the accept-loop task pushes a `ServerListening` event so @@ -704,13 +704,25 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, 0 => js_net_callback_ptr(arg2), cb => cb, }; - // #2013: a numeric `port` must be an integer in [0, 65536); Node throws - // RangeError [ERR_SOCKET_BAD_PORT] otherwise. (A string is a pipe path and - // is left alone.) - js_net_validate_listen_port(port); - let port_u16 = port as u16; - let host = string_from_header_i64(js_get_string_pointer_unified(arg2)) - .unwrap_or_else(|| "0.0.0.0".to_string()); + let path = ipc::string_value(port) + .or_else(|| is_nanboxed_pointer(port).then(|| get_object_string_field(port, "path"))?); + let (port_u16, host) = if path.is_some() { + (0, String::new()) + } else if is_nanboxed_pointer(port) { + let option_port = get_object_number_field(port, "port").unwrap_or(0.0); + js_net_validate_listen_port(option_port); + let option_host = get_object_string_field(port, "host") + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "0.0.0.0".to_string()); + (option_port as u16, option_host) + } else { + // #2013: a numeric `port` must be an integer in [0, 65536); Node throws + // RangeError [ERR_SOCKET_BAD_PORT] otherwise. + js_net_validate_listen_port(port); + let host = string_from_header_i64(js_get_string_pointer_unified(arg2)) + .unwrap_or_else(|| "0.0.0.0".to_string()); + (port as u16, host) + }; let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); @@ -728,6 +740,7 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, s.shutdown_tx = Some(shutdown_tx); s.bound_port = port_u16; s.bound_host = host.clone(); + s.bound_path = path.clone(); s.listening = true; } @@ -748,6 +761,11 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, let host_for_spawn = host.clone(); let server_id = handle; + if let Some(path) = path { + ipc::spawn_listener(server_id, path, shutdown_rx); + return; + } + // Run the accept loop cooperatively on Perry's shared multi-thread runtime // via `spawn_async` — no throwaway current-thread runtime, no blocking-pool // thread held for the server's life. The shared runtime owns the I/O @@ -810,34 +828,6 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, push_event(PendingNetEvent::ServerDrop(server_id, info)); continue; } - // Allocate a fresh Socket handle that - // shares the existing socket machinery - // (run_socket_task, command channel, - // 'data'/'end'/'close'/'error' pump - // dispatch). The accept side doesn't - // need a tokio TcpStream::connect — we - // already have the stream — so we - // bypass `spawn_socket_task` (which - // calls TcpStream::connect inside) and - // call `run_socket_task` directly with - // the accepted stream. - let socket_id = next_id(); - // #6441: on a background thread there is no JS frame - // to unwind to, so exhaustion can't throw here. - // Drop the accepted stream — closing the connection, - // the EMFILE-style degradation Node applies when it - // can't accept — rather than register a phantom - // socket under the `0` sentinel. Refuse quietly: once - // the band is exhausted every accept fails, so an - // 'error' event per connection would flood a hot - // loop; the synchronous client-facing entry points - // still surface a throwable EMFILE. - if socket_id == perry_ffi::INVALID_HANDLE { - server_state::cancel_pending_connection(server_id); - drop(stream); - continue; - } - let (tx, rx) = mpsc::unbounded_channel::(); // Node sets TCP_NODELAY on every accepted socket by // default (Nagle off). Match that so small writes // aren't delayed waiting to coalesce; a later @@ -849,64 +839,11 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, // bound port/family instead of returning // undefined. let accepted_local = stream.local_addr().ok(); - statics::sockets().lock().unwrap().insert( - socket_id, - SocketState { - cmd_tx: tx, - pending_rx: None, - is_open: true, - refed: true, - local_addr: accepted_local, - raw: None, - destroyed: false, - bytes_read: 0, - bytes_written: 0, - timeout: None, - type_of_service: 0, - server_id: Some(server_id), - server_connection_active: false, - tls: TlsSocketMetadata::default(), - }, + ipc::register_accepted_transport( + server_id, + Transport::Plain(stream), + accepted_local, ); - statics::listeners() - .lock() - .unwrap() - .insert(socket_id, HashMap::new()); - - // Surface the new socket to the user's - // `'connection'` listener on the main - // thread *before* spawning the read - // loop — the listener typically registers - // its own `.on('data', ...)` handlers - // and we want those in place before - // bytes start arriving. The accepted - // stream's read loop spawns next. - push_event(PendingNetEvent::ServerConnection( - server_id, socket_id, false, - )); - - // Spawn the per-socket read/write loop on - // the same shared runtime as this accept - // loop. A direct `tokio::spawn` (not - // `spawn_socket_runner`, which routes - // through the `perry_ffi::spawn_async` FFI - // shim) is correct here because we're - // already inside a task on the shared - // runtime — `tokio::spawn` lands on it - // directly, skipping the round-trip back - // out through C. The shim only matters at - // the FFI entry points that cross into - // Rust-from-C, where no ambient runtime - // task exists yet. - tokio::spawn(async move { - let mut rx = rx; - run_socket_task( - socket_id, - Transport::Plain(stream), - &mut rx, - ) - .await; - }); } Err(e) => { push_event(PendingNetEvent::ServerError( @@ -982,6 +919,12 @@ pub unsafe extern "C" fn js_net_server_address(handle: i64) -> *mut StringHeader let json = match statics::servers().lock() { Ok(g) => match g.get(&handle) { Some(s) if s.listening => { + if let Some(path) = &s.bound_path { + return alloc_string( + &serde_json::to_string(path).unwrap_or_else(|_| "null".to_string()), + ) + .as_raw(); + } let family = if s.bound_host.contains(':') { "IPv6" } else { @@ -1023,8 +966,8 @@ pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) // ─── FFI: socket.connect(port, host) (instance method on existing handle) ───── -/// `socket.connect(port, host)` — initiates a TCP connection on a socket -/// previously allocated by `new net.Socket()`. Pulls its receiver out of +/// `socket.connect(port, host)` / `socket.connect(path)` — initiates a TCP or +/// IPC connection on a socket previously allocated by `new net.Socket()`. Pulls its receiver out of /// the `SocketState::pending_rx` slot rather than allocating a fresh /// channel, so any listener already registered (`sock.on('data', cb)`) /// sees the same handle id once the connect completes. @@ -1033,21 +976,63 @@ pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) /// /// See `js_net_socket_connect`. #[no_mangle] -pub unsafe extern "C" fn js_net_socket_method_connect(handle: i64, port: f64, host_ptr: i64) { - // #2013: validate the port first (RangeError [ERR_SOCKET_BAD_PORT]), - // before any host handling, matching Node's `Socket.prototype.connect`. - js_net_validate_connect_port(port); - let host = match string_from_header_i64(host_ptr) { - Some(h) => h, - None => { - push_event(PendingNetEvent::Error( - handle, - "socket.connect: invalid host string".to_string(), - )); +pub unsafe extern "C" fn js_ext_net_socket_method_connect( + handle: i64, + arg1: f64, + arg2: f64, + arg3: f64, +) { + js_net_socket_method_connect(handle, arg1, arg2, arg3); +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_method_connect( + handle: i64, + arg1: f64, + arg2: f64, + arg3: f64, +) { + if let Some(path) = ipc::string_value(arg1) { + ipc::register_connect_cb(handle, arg2); + ipc::connect_existing(handle, path); + return; + } + + let (host, port, callback) = if is_nanboxed_pointer(arg1) { + if let Some(path) = get_object_string_field(arg1, "path") { + ipc::register_connect_cb(handle, arg2); + ipc::connect_existing(handle, path); return; } + let port = match get_object_number_field(arg1, "port") { + Some(port) => port, + None => { + push_event(PendingNetEvent::Error( + handle, + "socket.connect: options.port or options.path is required".to_string(), + )); + return; + } + }; + let host = get_object_string_field(arg1, "host") + .or_else(|| get_object_string_field(arg1, "hostname")) + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| "localhost".to_string()); + (host, port, arg2) + } else { + let host = ipc::string_value(arg2); + let callback = if host.is_some() { arg3 } else { arg2 }; + ( + host.unwrap_or_else(|| "127.0.0.1".to_string()), + arg1, + callback, + ) }; + // #2013: validate before truncating, matching Node's synchronous + // ERR_SOCKET_BAD_PORT behavior for positional and options overloads. + js_net_validate_connect_port(port); let port = port as u16; + ipc::register_connect_cb(handle, callback); let rx = { let mut guard = statics::sockets().lock().unwrap(); @@ -1374,6 +1359,12 @@ pub(crate) async fn run_socket_task( transport = Some(already_tls); let _ = reply.send(Err("socket is already TLS".to_string())); } + Some(ipc @ Transport::Ipc(_)) => { + transport = Some(ipc); + let _ = reply.send(Err( + "TLS upgrade is unsupported for IPC sockets".to_string(), + )); + } None => { let _ = reply.send(Err("socket closed".to_string())); break; diff --git a/crates/perry-ext-net/src/server_state.rs b/crates/perry-ext-net/src/server_state.rs index 5c03dcdbb3..9feadc9dc3 100644 --- a/crates/perry-ext-net/src/server_state.rs +++ b/crates/perry-ext-net/src/server_state.rs @@ -220,6 +220,18 @@ pub(crate) fn build_drop_object(info: &DropInfo) -> f64 { } pub(crate) fn should_drop_connection(server_id: i64, stream: &TcpStream) -> Option { + reserve_connection(server_id, stream.local_addr().ok(), stream.peer_addr().ok()) +} + +pub(crate) fn should_drop_ipc_connection(server_id: i64) -> Option { + reserve_connection(server_id, None, None) +} + +fn reserve_connection( + server_id: i64, + local: Option, + remote: Option, +) -> Option { let mut servers = statics::servers().lock().ok()?; let server = servers.get_mut(&server_id)?; if server @@ -227,10 +239,7 @@ pub(crate) fn should_drop_connection(server_id: i64, stream: &TcpStream) -> Opti .is_some_and(|max| server.active_connections + server.pending_connections >= max) && server.drop_max_connection.unwrap_or(false) { - return Some(DropInfo { - local: stream.local_addr().ok(), - remote: stream.peer_addr().ok(), - }); + return Some(DropInfo { local, remote }); } server.pending_connections += 1; None @@ -265,6 +274,26 @@ pub(crate) fn begin_local_connect(host: &str, port: u16) -> Option<(i64, bool)> Some((*server_id, expects_drop)) } +pub(crate) fn begin_local_path_connect(path: &str) -> Option<(i64, bool)> { + let mut servers = statics::servers().lock().ok()?; + let (server_id, server) = servers + .iter_mut() + .find(|(_, server)| server.listening && server.bound_path.as_deref() == Some(path))?; + let completed = connection_order_state() + .lock() + .unwrap() + .completed_local_connects + .get(server_id) + .copied() + .unwrap_or(0); + let expects_drop = server.drop_max_connection.unwrap_or(false) + && server.max_connections.is_some_and(|max| { + server.active_connections + server.pending_connections + completed >= max + }); + server.pending_local_connect_events += 1; + Some((*server_id, expects_drop)) +} + fn complete_local_connect(local_server: Option<(i64, bool)>, connected: bool) { let Some((server_id, expects_drop)) = local_server else { return; diff --git a/crates/perry-ext-net/src/test_async_shims.rs b/crates/perry-ext-net/src/test_async_shims.rs index 4a80fc1da0..fb51459b10 100644 --- a/crates/perry-ext-net/src/test_async_shims.rs +++ b/crates/perry-ext-net/src/test_async_shims.rs @@ -1,4 +1,4 @@ -use perry_ffi::Promise; +use perry_ffi::{NativeAsyncCompletion, Promise}; use std::ffi::c_void; // Unit-test binaries do not link the host stdlib/runtime archive that normally @@ -46,3 +46,70 @@ pub extern "C" fn perry_ffi_spawn_blocking_with_reactor( ) { invoke(ctx); } + +// The native-completion ABI is linked into perry-ffi even though ext-net's +// tests do not exercise it. Keep inert definitions here so the standalone +// crate test binary does not need the full Perry host archive. +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_new(_flags: u32) -> *mut NativeAsyncCompletion { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_promise( + _token: *mut NativeAsyncCompletion, +) -> *mut Promise { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_resolve_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_string( + _token: *mut NativeAsyncCompletion, + _data: *const u8, + _len: usize, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_cancel(_token: *mut NativeAsyncCompletion) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_attach_handle( + _token: *mut NativeAsyncCompletion, + _handle_bits: u64, + _cleanup_flags: u32, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} + +#[no_mangle] +pub extern "C" fn js_tls_client_preflight( + _port: f64, + _servername_ptr: *const u8, + _servername_len: usize, + _options: f64, +) -> i32 { + 0 +} diff --git a/crates/perry-ext-net/src/transport.rs b/crates/perry-ext-net/src/transport.rs index 6d1897bf36..d725cb1abf 100644 --- a/crates/perry-ext-net/src/transport.rs +++ b/crates/perry-ext-net/src/transport.rs @@ -11,9 +11,14 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::TcpStream; use tokio_rustls::client::TlsStream; +pub(crate) trait IpcStream: AsyncRead + AsyncWrite + Send + Unpin {} + +impl IpcStream for T where T: AsyncRead + AsyncWrite + Send + Unpin {} + pub(crate) enum Transport { Plain(TcpStream), Tls(Box>), + Ipc(Box), } impl Transport { @@ -25,6 +30,9 @@ impl Transport { match self { Transport::Plain(s) => s.set_nodelay(nodelay), Transport::Tls(s) => s.get_ref().0.set_nodelay(nodelay), + // Pipes do not use Nagle's algorithm. Node accepts setNoDelay on + // every net.Socket, including pipe-backed sockets, as a no-op. + Transport::Ipc(_) => Ok(()), } } @@ -35,6 +43,7 @@ impl Transport { match self { Transport::Plain(s) => s.nodelay(), Transport::Tls(s) => s.get_ref().0.nodelay(), + Transport::Ipc(_) => Ok(false), } } } @@ -48,6 +57,7 @@ impl AsyncRead for Transport { match self.get_mut() { Transport::Plain(s) => Pin::new(s).poll_read(cx, buf), Transport::Tls(s) => Pin::new(&mut **s).poll_read(cx, buf), + Transport::Ipc(s) => Pin::new(&mut **s).poll_read(cx, buf), } } } @@ -61,18 +71,21 @@ impl AsyncWrite for Transport { match self.get_mut() { Transport::Plain(s) => Pin::new(s).poll_write(cx, buf), Transport::Tls(s) => Pin::new(&mut **s).poll_write(cx, buf), + Transport::Ipc(s) => Pin::new(&mut **s).poll_write(cx, buf), } } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.get_mut() { Transport::Plain(s) => Pin::new(s).poll_flush(cx), Transport::Tls(s) => Pin::new(&mut **s).poll_flush(cx), + Transport::Ipc(s) => Pin::new(&mut **s).poll_flush(cx), } } fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.get_mut() { Transport::Plain(s) => Pin::new(s).poll_shutdown(cx), Transport::Tls(s) => Pin::new(&mut **s).poll_shutdown(cx), + Transport::Ipc(s) => Pin::new(&mut **s).poll_shutdown(cx), } } } diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index df90315ec6..2819ddb2dd 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -24,6 +24,7 @@ pub(crate) mod lower_decl; pub(crate) mod lower_patterns; pub(crate) mod lower_types; pub mod monomorph; +pub mod native_profile; pub mod stable_hash; pub mod types; pub mod walker; @@ -64,3 +65,4 @@ pub use lower::{ lower_module_with_class_id_types_seed_and_entry, }; pub use monomorph::monomorphize_module; +pub use native_profile::exported_native_pod_abi; diff --git a/crates/perry-hir/src/native_profile.rs b/crates/perry-hir/src/native_profile.rs new file mode 100644 index 0000000000..32d0a8b5c0 --- /dev/null +++ b/crates/perry-hir/src/native_profile.rs @@ -0,0 +1,259 @@ +//! Source-contract extraction for the public `perry/native` POD profile. + +use std::collections::HashSet; + +use perry_api_manifest::{NativeAbiType, NativePodAbi, NativePodFieldAbi}; + +use crate::{types::Type, Interface, Module}; + +/// Resolve an exported `pod` alias into the canonical native-library POD +/// descriptor used by manifest validation and code generation. +pub fn exported_native_pod_abi(module: &Module, export_name: &str) -> Result { + let alias = module + .type_aliases + .iter() + .find(|alias| alias.is_exported && alias.name == export_name) + .ok_or_else(|| format!("source export `{export_name}` is not an exported type alias"))?; + if !alias.type_params.is_empty() { + return Err(format!( + "source export `{export_name}` must not declare type parameters" + )); + } + let Type::Generic { base, type_args } = &alias.ty else { + return Err(format!( + "source export `{export_name}` must be declared as pod" + )); + }; + if base != "PerryPod" || type_args.len() != 1 { + return Err(format!( + "source export `{export_name}` must be declared as pod" + )); + } + + let mut resolving = HashSet::new(); + let fields = pod_fields(module, &type_args[0], &mut resolving)?; + if fields.is_empty() { + return Err(format!( + "source POD `{export_name}` must contain at least one field" + )); + } + Ok(NativePodAbi { + name: Some(export_name.to_string()), + fields, + }) +} + +fn pod_fields( + module: &Module, + ty: &Type, + resolving: &mut HashSet, +) -> Result, String> { + match ty { + Type::Object(object) => { + if object.index_signature.is_some() { + return Err("POD object types cannot contain an index signature".to_string()); + } + let order = object.property_order.as_ref().ok_or_else(|| { + "POD object type is missing stable source property order".to_string() + })?; + order + .iter() + .map(|name| { + let property = object.properties.get(name).ok_or_else(|| { + format!("POD property `{name}` is missing from its object type") + })?; + if property.optional { + return Err(format!("POD field `{name}` must not be optional")); + } + Ok(NativePodFieldAbi { + name: name.clone(), + ty: pod_field_type(module, &property.ty, resolving)?, + }) + }) + .collect() + } + Type::Named(name) => { + if !resolving.insert(name.clone()) { + return Err(format!( + "recursive POD source type `{name}` is not supported" + )); + } + let result = if let Some(alias) = module.type_aliases.iter().find(|a| a.name == *name) { + if !alias.type_params.is_empty() { + Err(format!( + "POD source type alias `{name}` must not be generic" + )) + } else { + pod_fields(module, &alias.ty, resolving) + } + } else if let Some(interface) = module.interfaces.iter().find(|i| i.name == *name) { + interface_fields(module, interface, resolving) + } else { + Err(format!("POD source type `{name}` could not be resolved")) + }; + resolving.remove(name); + result + } + _ => Err("pod requires an object literal, interface, or object type alias".to_string()), + } +} + +fn interface_fields( + module: &Module, + interface: &Interface, + resolving: &mut HashSet, +) -> Result, String> { + if !interface.type_params.is_empty() || !interface.extends.is_empty() { + return Err(format!( + "POD interface `{}` must not be generic or extend another interface", + interface.name + )); + } + if !interface.methods.is_empty() { + return Err(format!( + "POD interface `{}` must not contain methods", + interface.name + )); + } + interface + .properties + .iter() + .map(|property| { + if property.optional { + return Err(format!( + "POD field `{}` must not be optional", + property.name + )); + } + Ok(NativePodFieldAbi { + name: property.name.clone(), + ty: pod_field_type(module, &property.ty, resolving)?, + }) + }) + .collect() +} + +fn pod_field_type( + module: &Module, + ty: &Type, + resolving: &mut HashSet, +) -> Result { + let scalar = match ty { + Type::Number => Some(NativeAbiType::F64), + Type::Named(name) => match name.as_str() { + "PerryI8" => Some(NativeAbiType::I8), + "PerryI16" => Some(NativeAbiType::I16), + "PerryI32" => Some(NativeAbiType::I32), + "PerryI64" => Some(NativeAbiType::I64), + "PerryU8" | "PerryByte" => Some(NativeAbiType::U8), + "PerryU16" => Some(NativeAbiType::U16), + "PerryU32" => Some(NativeAbiType::U32), + "PerryU64" => Some(NativeAbiType::U64), + "PerryISize" => Some(NativeAbiType::ISize), + "PerryUSize" => Some(NativeAbiType::USize), + "PerryF32" => Some(NativeAbiType::F32), + "PerryF64" => Some(NativeAbiType::F64), + "PerryBufferLen" => Some(NativeAbiType::BufferLen), + "PerryHandleId" => Some(NativeAbiType::HandleId), + _ => None, + }, + _ => None, + }; + if let Some(scalar) = scalar { + return Ok(scalar); + } + + match ty { + Type::Generic { base, type_args } if base == "PerryPod" && type_args.len() == 1 => { + Ok(NativeAbiType::Pod(NativePodAbi { + name: None, + fields: pod_fields(module, &type_args[0], resolving)?, + })) + } + Type::Named(name) => { + if !resolving.insert(name.clone()) { + return Err(format!( + "recursive POD source type `{name}` is not supported" + )); + } + let result = if let Some(alias) = module.type_aliases.iter().find(|a| a.name == *name) { + if !alias.type_params.is_empty() { + Err(format!("POD field type alias `{name}` must not be generic")) + } else { + pod_field_type(module, &alias.ty, resolving) + } + } else { + Err(format!( + "POD field type `{name}` is not a fixed-width scalar or nested pod" + )) + }; + resolving.remove(name); + result + } + _ => Err(format!( + "POD field type `{ty:?}` is not a fixed-width scalar or nested pod" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lower(source: &str) -> Module { + let ast = perry_parser::parse_typescript(source, "native-contract.ts").expect("parse"); + crate::set_current_module_source(source.to_string()); + let module = crate::lower_module(&ast, "native-contract", "native-contract.ts"); + crate::clear_current_module_source(); + module.expect("lower") + } + + #[test] + fn extracts_all_public_scalar_widths_and_nested_pods_in_source_order() { + let module = lower( + r#" +import type { pod, i8, i16, i32, i64, u8, byte, u16, u32, u64, isize, usize, f32, f64 } from "perry/native"; +interface Nested { code: u16; weight: f32 } +export type Packet = pod<{ + a: i8; b: i16; c: i32; d: i64; + e: u8; f: byte; g: u16; h: u32; i: u64; + j: isize; k: usize; l: f32; m: f64; n: number; + nested: pod; +}>; +"#, + ); + + let pod = exported_native_pod_abi(&module, "Packet").expect("extract POD"); + let names: Vec<_> = pod.fields.iter().map(|field| field.name.as_str()).collect(); + assert_eq!( + names, + ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "nested"] + ); + assert_eq!(pod.fields[0].ty, NativeAbiType::I8); + assert_eq!(pod.fields[5].ty, NativeAbiType::U8); + assert_eq!(pod.fields[9].ty, NativeAbiType::ISize); + assert_eq!(pod.fields[13].ty, NativeAbiType::F64); + let NativeAbiType::Pod(nested) = &pod.fields[14].ty else { + panic!("nested field must be a POD") + }; + assert_eq!(nested.fields[0].ty, NativeAbiType::U16); + assert_eq!(nested.fields[1].ty, NativeAbiType::F32); + } + + #[test] + fn requires_an_exported_closed_pod_alias() { + let module = lower( + r#" +import type { pod, u32 } from "perry/native"; +type Hidden = pod<{ value: u32 }>; +export type Optional = pod<{ value?: u32 }>; +"#, + ); + assert!(exported_native_pod_abi(&module, "Hidden") + .unwrap_err() + .contains("not an exported type alias")); + assert!(exported_native_pod_abi(&module, "Optional") + .unwrap_err() + .contains("must not be optional")); + } +} diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index 3ac80518dd..f02ee16750 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -179,16 +179,13 @@ global-webfetch = [] # detection would make `process.send` undefined, so err toward enabling). proc-ipc = [] # `Intl.getCanonicalLocales` / `*.supportedLocalesOf` BCP-47 (UTS #35) language-tag -# canonicalization via `icu_locale_core` (the data-free structural parser — case -# normalization, variant ordering, extension well-formedness, UTS35 rejection of -# extlang/grandfathered/duplicate-singleton tags). No CLDR data is pulled (that -# would need `icu_locale` + `icu_locale_data`), so deep alias replacement -# (grandfathered→preferred, complex subtag replacement) is out of scope. Only -# `intl.rs` uses it; a program that never canonicalizes a locale links none of -# it (the compiler enables it on `Intl.getCanonicalLocales`/`supportedLocalesOf` -# usage). Already pulled transitively by `temporal`, so default/shipped builds -# carry no extra weight. A hand-rolled structural fallback covers the off case. -intl-locale = ["dep:icu_locale_core"] +# canonicalization via ICU4X's structural parser plus compiled CLDR aliases and +# likely-subtag data. Only Intl locale operations use it; a program that never +# canonicalizes or expands a locale links none of it. The same data is already +# pulled transitively by `intl-datetime` in default builds, so the shipped full +# runtime carries no duplicate tables. A hand-rolled fallback covers the off +# case for size-optimized builds. +intl-locale = ["dep:icu_locale", "dep:icu_locale_core"] # CLDR-accurate Intl.DateTimeFormat / toLocaleString date-time patterns. intl-datetime = ["dep:icu_datetime", "dep:icu_time", "dep:icu_calendar", "dep:icu_locale_core"] # `full` only opt-ins the small Node-API helpers (os.hostname / os.homedir). @@ -302,9 +299,10 @@ unicode-normalization = { version = "0.1", optional = true } # Intl.Segmenter (the grapheme path is what string-width@7+/wrap-ansi@9+ use, # so it gates ink). Pure-Rust UAX #29 implementation, already in our lock graph. unicode-segmentation = { version = "1", optional = true } -# #5298: BCP-47 (UTS #35) structural locale-tag canonicalization for -# `Intl.getCanonicalLocales` / `*.supportedLocalesOf`. The data-free structural -# parser only (no CLDR alias tables); already in our lock graph via temporal_rs. +# #5298/#5896: BCP-47 (UTS #35) structural locale-tag canonicalization, CLDR +# aliases, and likely-subtag expansion for the Intl locale APIs. Both crates and +# their compiled data are already in the default lock graph via icu_datetime. +icu_locale = { version = "2", optional = true } icu_locale_core = { version = "2", optional = true } # CLDR date/time formatting for Intl.DateTimeFormat / Date.prototype.toLocale* # (icu4x 2.x, matching the icu_calendar/icu_locale_core already in the graph). diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index a86a4c6748..65e31deeb4 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -974,7 +974,36 @@ pub extern "C" fn js_array_set_length(arr: *mut ArrayHeader, new_length: f64) { // index 0 cannot remain observable after its getter truncates the // array to zero. If a non-configurable index blocks deletion, keep // that index and restore length to index + 1 per §10.4.2.4. - for i in (n..cur).rev() { + // + // A large logical extension does not allocate its holes (see the + // growth branch below), so do not walk those holes when the length + // is restored. Far materialized indices live in the named-property + // table. Delete them first, in the same descending order required + // by ArraySetLength, then visit the allocated dense prefix. + let capacity = (*arr).capacity; + if cur > capacity { + let mut sparse_indices: Vec = array_named_property_names(arr, false) + .into_iter() + .filter_map(|name| { + let index = name.parse::().ok()?; + (index != u32::MAX + && index >= n.max(capacity) + && index < cur + && index.to_string() == name) + .then_some(index) + }) + .collect(); + sparse_indices.sort_unstable_by(|a, b| b.cmp(a)); + sparse_indices.dedup(); + for i in sparse_indices { + if js_array_delete(arr, i) == 0 { + (*arr).length = i + 1; + refresh_array_numeric_layout(arr); + return; + } + } + } + for i in (n..cur.min(capacity)).rev() { if js_array_delete(arr, i) == 0 { (*arr).length = i + 1; refresh_array_numeric_layout(arr); @@ -984,6 +1013,15 @@ pub extern "C" fn js_array_set_length(arr: *mut ArrayHeader, new_length: f64) { (*arr).length = n; refresh_array_numeric_layout(arr); } else if n > cur { + // Growing `length` creates holes conceptually; it must not allocate + // a dense backing store proportional to the requested length. + // Test262's descriptor probe writes 2^32-1 here. Keep large sparse + // extensions logical and let later indexed writes choose storage. + if n > (*arr).capacity && n > 1_000_000 { + (*arr).length = n; + refresh_array_numeric_layout(arr); + return; + } // Extend: pad with TAG_HOLE. Past-capacity extensions go // through `js_array_grow` which installs a forwarding pointer at // the OLD location (issue #233 mechanism), so the caller's stale diff --git a/crates/perry-runtime/src/array/tests.rs b/crates/perry-runtime/src/array/tests.rs index 11f1e32e8e..151e794cbf 100644 --- a/crates/perry-runtime/src/array/tests.rs +++ b/crates/perry-runtime/src/array/tests.rs @@ -1187,6 +1187,20 @@ fn test_numeric_array_layout_length_and_delete_transitions() { } } +#[test] +fn large_length_growth_stays_logically_sparse() { + let mut arr = js_array_alloc(1); + arr = js_array_push_f64(arr, 1.0); + js_array_set_length(arr, u32::MAX as f64); + + assert_eq!(js_array_length(arr), u32::MAX); + assert!(unsafe { (*arr).capacity } <= 1_000_000); + + js_array_set_length(arr, 1.0); + assert_eq!(js_array_length(arr), 1); + assert_eq!(array_spec_get(arr, 0), 1.0); +} + #[test] fn test_numeric_array_layout_immutable_helpers_preserve_or_downgrade() { let values = [10.0, 2.0, 30.0, 40.0]; diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 57f26a4a3d..0a003e2870 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -40,6 +40,17 @@ mod time_zone; pub(crate) use time_zone::resolved_date_time_zone; mod install; use install::install_constructor; +mod method_install; +use method_install::{ + install_bound_instance_function, install_bound_instance_function_from_handle, install_function, + install_function_from_handle, +}; +mod rooted_fields; +use rooted_fields::{ + get_field, get_field_from_raw_handle, get_field_from_value_handle, get_number_field, + get_option_value, get_string_field, get_string_field_from_raw_handle, set_builtin_attrs, + set_field, set_internal_field, set_internal_field_from_raw_handle, +}; mod subclass; pub(crate) use subclass::{intl_instanceof, intl_subclass_super, is_intl_constructor_value}; use subclass::{locale_instance_tag, push_locale_element}; @@ -61,18 +72,16 @@ pub(crate) use date_collator::{ date_time_format_bound_to_parts_thunk, date_time_format_format_getter_thunk, date_time_format_range_thunk, date_time_format_range_to_parts_thunk, date_time_format_resolved_options_thunk, date_time_format_to_parts_thunk, - temporal_locale_string, TemporalLocaleCtx, + resolve_collator_locale, temporal_locale_string, TemporalLocaleCtx, }; pub(crate) use list_relative_plural::{ canonicalize_calendar_id, canonicalize_offset_time_zone, is_valid_offset_time_zone, list_format_bound_format_thunk, list_format_bound_resolved_options_thunk, list_format_bound_to_parts_thunk, list_format_format_thunk, list_format_parts, list_format_resolved_options_thunk, list_format_to_parts_thunk, - plural_rules_bound_resolved_options_thunk, plural_rules_bound_select_range_thunk, - plural_rules_bound_select_thunk, plural_rules_resolved_options_thunk, - plural_rules_select_range_thunk, plural_rules_select_thunk, rtf_bound_format_thunk, - rtf_bound_resolved_options_thunk, rtf_bound_to_parts_thunk, rtf_format_thunk, - rtf_resolved_options_thunk, rtf_to_parts_thunk, + plural_rules_resolved_options_thunk, plural_rules_select_range_thunk, + plural_rules_select_thunk, rtf_bound_format_thunk, rtf_bound_resolved_options_thunk, + rtf_bound_to_parts_thunk, rtf_format_thunk, rtf_resolved_options_thunk, rtf_to_parts_thunk, }; pub(crate) use number_format::{ bigint_to_locale_string, captured_intl_object, nf_resolved_default, @@ -137,6 +146,7 @@ const KEY_TYPE: &str = "__intlType"; const KEY_LF_STYLE: &str = "__intlListStyle"; const KEY_NUMERIC: &str = "__intlNumeric"; const KEY_RTF_STYLE: &str = "__intlRtfStyle"; +const KEY_RTF_NUMBERING: &str = "__intlRtfNumbering"; const KEY_PR_MIN_INT: &str = "__intlMinInt"; const KEY_PR_MIN_FRAC: &str = "__intlMinFrac"; const KEY_PR_MAX_FRAC: &str = "__intlMaxFrac"; @@ -251,46 +261,6 @@ fn array_ptr_from_value(value: f64) -> Option<*const crate::ArrayHeader> { (!ptr.is_null()).then_some(ptr) } -fn get_field(value: *const ObjectHeader, key: &str) -> f64 { - let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); - js_object_get_field_by_name_f64(value, key_ptr) -} - -fn set_field(obj: *mut ObjectHeader, key: &str, value: f64) { - let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); - js_object_set_field_by_name(obj, key_ptr, value); -} - -fn set_builtin_attrs(obj: *mut ObjectHeader, key: &str, attrs: PropertyAttrs) { - set_builtin_property_attrs(obj as usize, key.to_string(), attrs); -} - -fn set_internal_field(obj: *mut ObjectHeader, key: &str, value: f64) { - set_field(obj, key, value); - set_builtin_attrs(obj, key, PropertyAttrs::new(true, false, true)); -} - -fn get_string_field(obj: *const ObjectHeader, key: &str) -> Option { - string_from_string_value(get_field(obj, key)) -} - -fn get_number_field(obj: *const ObjectHeader, key: &str) -> Option { - let value = get_field(obj, key); - let js = JSValue::from_bits(value.to_bits()); - if js.is_undefined() || js.is_null() { - None - } else { - Some(js.to_number()) - } -} - -fn get_option_value(options: f64, key: &str) -> f64 { - let Some(obj) = object_ptr_from_value(options) else { - return undefined(); - }; - get_field(obj, key) -} - /// Coerce an already-fetched option value to its GetOption string form. ECMA-402 /// GetOption treats ONLY `undefined` as "absent → fallback"; every other value — /// `null` included — is coerced with ToString and then checked against the @@ -519,22 +489,26 @@ pub(crate) fn canonical_locale(tag: &str) -> Option { /// canonicalization. Returns `None` when the tag is not a structurally valid /// `unicode_locale_id` (the caller raises `RangeError`). /// -/// With the `intl-locale` feature this delegates to `icu_locale_core`'s data-free -/// structural parser, which gives correct case normalization, variant ordering, -/// extension well-formedness, and UTS #35 rejection of extlang / grandfathered / -/// duplicate-singleton tags. (Deep CLDR alias replacement — -/// grandfathered→preferred, complex subtag replacement, unicode-extension value -/// aliases — needs `icu_locale` + its CLDR data and is out of scope.) The -/// fallback path uses the lighter hand-rolled `canonical_locale`. +/// With the `intl-locale` feature this delegates to ICU4X's structural parser +/// and compiled CLDR canonicalizer, which cover case/variant/extension +/// normalization as well as language, script, region, variant, and transformed +/// extension aliases. Perry's small post-pass supplies the handful of Unicode +/// extension type aliases that ICU4X does not currently include. The fallback +/// path uses the lighter hand-rolled `canonical_locale`. fn canonicalize_language_tag(tag: &str) -> Option { #[cfg(feature = "intl-locale")] { - match icu_locale_core::Locale::normalize(tag) { - Ok(canonical) => Some(canonicalize_unicode_extension_types( - &canonical.into_owned(), - )), - Err(_) => None, - } + let mut locale = match tag.parse::() { + Ok(locale) => locale, + Err(_) + if (5..=8).contains(&tag.len()) && tag.bytes().all(|b| b.is_ascii_alphabetic()) => + { + return Some(tag.to_ascii_lowercase()); + } + Err(_) => return None, + }; + icu_locale::LocaleCanonicalizer::new_extended().canonicalize(&mut locale); + Some(canonicalize_unicode_extension_types(&locale.to_string())) } #[cfg(not(feature = "intl-locale"))] { @@ -542,15 +516,27 @@ fn canonicalize_language_tag(tag: &str) -> Option { } } -/// `HasProperty(O, ToString(index))` — true when the integer-indexed property is -/// present (own or inherited). Used to skip holes/absent indices in -/// CanonicalizeLocaleList's array/array-like walk. -fn js_has_index(obj: f64, index: u32) -> bool { - let key = string_value(&index.to_string()); - crate::object::js_object_has_property(obj, key).to_bits() == crate::value::TAG_TRUE +/// CanonicalizeLocaleList's `HasProperty(O, ToString(index))`. +fn js_has_index(obj: &crate::gc::RuntimeHandle<'_>, index: u32) -> bool { + let scope = crate::gc::RuntimeHandleScope::new(); + let key = scope.root_nanbox_f64(string_value(&index.to_string())); + if crate::proxy::js_proxy_is_proxy(obj.get_nanbox_f64()) != 0 { + return crate::proxy::js_proxy_has(obj.get_nanbox_f64(), key.get_nanbox_f64()).to_bits() + == crate::value::TAG_TRUE; + } + crate::object::js_object_has_property(obj.get_nanbox_f64(), key.get_nanbox_f64()).to_bits() + == crate::value::TAG_TRUE +} + +fn proxy_get_from_value_handle(value: &crate::gc::RuntimeHandle<'_>, key: &str) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let key = scope.root_nanbox_f64(string_value(key)); + crate::proxy::js_proxy_get(value.get_nanbox_f64(), key.get_nanbox_f64()) } fn locales_from_value(locales: f64) -> Vec { + let scope = crate::gc::RuntimeHandleScope::new(); + let locales_handle = scope.root_nanbox_f64(locales); let js = JSValue::from_bits(locales.to_bits()); // CanonicalizeLocaleList(undefined) is the empty list; `null` fails ToObject // with a TypeError (everything else is a String or coerces via ToObject). @@ -568,29 +554,57 @@ fn locales_from_value(locales: f64) -> Vec { }; return vec![canonical]; } - // CanonicalizeLocaleList step 2: a value with an `[[InitializedLocale]]` - // slot (an `Intl.Locale` / subclass instance) is the single-element list - // « locale », read from its slot — not iterated nor `toString`-ed. + // A Proxy must be classified before probing Locale/Array/Object headers: + // those probes reinterpret pointer payloads and a Proxy has a distinct GC + // layout. CanonicalizeLocaleList observes it through [[Get]]/[[HasProperty]] + // regardless of the target's underlying kind. + if crate::proxy::js_proxy_is_proxy(locales_handle.get_nanbox_f64()) != 0 { + let len = crate::builtins::js_number_coerce(proxy_get_from_value_handle( + &locales_handle, + "length", + )); + let mut out = Vec::new(); + for i in 0..if len.is_finite() && len > 0.0 { + len as u32 + } else { + 0 + } { + if js_has_index(&locales_handle, i) { + push_locale_element( + &mut out, + proxy_get_from_value_handle(&locales_handle, &i.to_string()), + ); + } + } + return out; + } + // An Intl.Locale contributes its internal locale instead of being iterated. if let Some(tag) = locale_instance_tag(locales) { let Some(canonical) = canonicalize_language_tag(&tag) else { throw_invalid_language_tag(&tag); }; return vec![canonical]; } - if let Some(arr) = array_ptr_from_value(locales) { + if let Some(arr) = array_ptr_from_value(locales_handle.get_nanbox_f64()) { let len = js_array_length(arr); let mut out = Vec::with_capacity(len as usize); for i in 0..len { + if !js_has_index(&locales_handle, i) { + continue; + } + let Some(arr) = array_ptr_from_value(locales_handle.get_nanbox_f64()) else { + break; + }; push_locale_element(&mut out, js_array_get_f64(arr, i)); } return out; } // CanonicalizeLocaleList on a generic array-like Object: iterate `O[0..length]` // (e.g. `{ 0: "DE", length: 1 }` → `["de"]`). - if let Some(obj) = object_ptr_from_value(locales) { + if object_ptr_from_value(locales_handle.get_nanbox_f64()).is_some() { // `length = ? ToLength(? Get(O, "length"))`: a throwing `length` getter or // ToNumber step (Symbol / abrupt valueOf/toString) propagates here. - let len_raw = get_field(obj, "length"); + let len_raw = get_field_from_value_handle(&locales_handle, "length"); let len_num = crate::builtins::js_number_coerce(len_raw); let len = if len_num.is_finite() && len_num > 0.0 { len_num as u32 @@ -601,16 +615,42 @@ fn locales_from_value(locales: f64) -> Vec { for i in 0..len { // Skip absent indices (`HasProperty` is false) — e.g. // `{ length: 3, 0: "en" }` yields just `["en"]`, never `undefined`. - if !js_has_index(locales, i) { + if !js_has_index(&locales_handle, i) { continue; } - push_locale_element(&mut out, get_field(obj, &i.to_string())); + push_locale_element( + &mut out, + get_field_from_value_handle(&locales_handle, &i.to_string()), + ); } return out; } - // Other primitives (number/boolean/Symbol/BigInt): ToObject yields a wrapper - // with length 0 — an empty list, no throw. - Vec::new() + // Other primitives (number/boolean/Symbol/BigInt): CanonicalizeLocaleList + // applies ToObject, so inherited `length` / indexed getters on the wrapper + // prototype remain observable (DisplayNames/locales-symbol-length.js). + let boxed = scope.root_nanbox_f64(crate::object::js_object_coerce( + locales_handle.get_nanbox_f64(), + )); + if object_ptr_from_value(boxed.get_nanbox_f64()).is_none() { + return Vec::new(); + } + let len_raw = get_field_from_value_handle(&boxed, "length"); + let len_num = crate::builtins::js_number_coerce(len_raw); + let len = if len_num.is_finite() && len_num > 0.0 { + len_num as u32 + } else { + 0 + }; + let mut out = Vec::with_capacity(len as usize); + for i in 0..len { + if js_has_index(&boxed, i) { + push_locale_element( + &mut out, + get_field_from_value_handle(&boxed, &i.to_string()), + ); + } + } + out } /// BestAvailableLocale (lookup) — a requested canonical locale is "supported" @@ -872,27 +912,19 @@ fn enum_option_strict(options: f64, key: &str, allowed: &[&str], default: &str) } } -/// `GetOptionsObject(options)`: `undefined` yields an empty bag (reported as -/// `undefined`, which the option readers treat as "every key absent"); an Object -/// passes through unchanged; any other value (including `null`, primitives, and -/// BigInt) throws `TypeError`. Used by the constructors whose spec step is -/// `GetOptionsObject` (ListFormat, Segmenter, PluralRules, …). +/// ECMA-402 GetOptionsObject. fn get_options_object(options: f64) -> f64 { let jv = JSValue::from_bits(options.to_bits()); if jv.is_undefined() { return options; } - if object_ptr_from_value(options).is_some() { + if crate::proxy::js_proxy_is_proxy(options) != 0 || object_ptr_from_value(options).is_some() { return options; } throw_type_error("Cannot convert undefined or null to object"); } -/// `CoerceOptionsToObject(options)` partial: `undefined` stays an empty bag and -/// `null` throws `TypeError` (`ToObject(null)`). Primitives are *not* boxed here -/// — Perry reads option keys directly off Objects, so a primitive simply yields -/// every-key-absent — but `null` must still reject. Used by the constructors -/// whose spec step is `ToObject` (RelativeTimeFormat, Collator, …). +/// CoerceOptionsToObject's null rejection; callers box primitives when needed. fn coerce_options_reject_null(options: f64) -> f64 { if JSValue::from_bits(options.to_bits()).is_null() { throw_type_error("Cannot convert undefined or null to object"); @@ -900,19 +932,11 @@ fn coerce_options_reject_null(options: f64) -> f64 { options } -/// `ToObject(options)` for the SupportedLocales option read: `null` / `undefined` -/// are handled by the caller; a non-object primitive (Boolean, Number, String, -/// Symbol, BigInt) is boxed into a fresh empty object so that reading an option -/// key walks the standard prototype chain and fires any `Object.prototype` -/// getter for that key exactly once (SupportedLocales step 1.a, test262 -/// `supportedLocalesOf/options-toobject.js`). A real object passes through. +/// Box a primitive so inherited Object.prototype option getters stay observable. fn to_object_for_options(options: f64) -> f64 { - if object_ptr_from_value(options).is_some() { + if crate::proxy::js_proxy_is_proxy(options) != 0 || object_ptr_from_value(options).is_some() { return options; } - // Box the primitive: an empty object has no own option keys, so every read - // resolves through the prototype chain — matching the boxed-wrapper behaviour - // the spec observes (the wrapper carries no `localeMatcher` of its own). js_nanbox_pointer(js_object_alloc(0, 0) as i64) } @@ -936,6 +960,8 @@ fn dt_component_option( allowed: &[&str], store_key: &str, ) -> bool { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); match get_option_string(options, key) { None => false, Some(value) => { @@ -944,12 +970,22 @@ fn dt_component_option( "Value {value} out of range for Intl options property {key}" )); } - set_internal_field(obj, store_key, string_value(&value)); + set_internal_field_from_raw_handle(&obj, store_key, string_value(&value)); true } } } +fn dt_component_option_from_handle( + obj: &crate::gc::RuntimeHandle<'_>, + options: f64, + key: &str, + allowed: &[&str], + store_key: &str, +) -> bool { + obj.with_mut_ptr(|obj| dt_component_option(obj, options, key, allowed, store_key)) +} + /// Validate a *named* (non-offset) `timeZone` identifier. Perry ships no tz /// database (see `date.rs`), so this is a structural check rather than a lookup: /// the case-insensitive UTC aliases normalize to `"UTC"`, the legacy @@ -959,14 +995,23 @@ fn dt_component_option( /// (`"MEZ"`, `"invalid"`, `"Europe/İstanbul"`, …) do not. Returns the (best /// effort, un-recased) canonical identifier, or `None` to signal `RangeError`. fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, options: f64) -> f64 { - let locale = locale_or_default(locales); + // Locale/option access can invoke user Proxy traps. Keep both arguments and + // the partially initialized result live across those calls; the handles are + // refreshed explicitly in the long PluralRules read-order sequence below. + let scope = crate::gc::RuntimeHandleScope::new(); + let closure_handle = scope.root_raw_const_ptr(closure); + let locales_handle = scope.root_nanbox_f64(locales); + let options_handle = scope.root_nanbox_f64(options); + let locale = locale_or_default(locales_handle.get_nanbox_f64()); let obj = js_object_alloc(0, 8); - set_internal_field(obj, KEY_KIND, string_value(kind)); - set_internal_field(obj, KEY_LOCALE, string_value(&locale)); + let obj_handle = scope.root_raw_mut_ptr(obj); + set_internal_field_from_raw_handle(&obj_handle, KEY_KIND, string_value(kind)); + set_internal_field_from_raw_handle(&obj_handle, KEY_LOCALE, string_value(&locale)); + let current_options = || options_handle.get_nanbox_f64(); match kind { KIND_NUMBER => { - configure_number_format(obj, &locale, options); + obj_handle.with_mut_ptr(|obj| configure_number_format(obj, &locale, current_options())); // The bound format function is the [[BoundFormat]] slot: ECMA-402 // gives it an empty `name` ("") and length 1. It is installed as an // own `format` property so `nf.format(x)` dispatches without the @@ -974,22 +1019,22 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // props), and is also stashed in the hidden KEY_NF_BOUND_FORMAT slot // that the prototype `format` getter reads — so mutating or deleting // the public property can't corrupt what the accessor returns. - let format_fn = install_bound_instance_function( - obj, + let format_fn = install_bound_instance_function_from_handle( + &obj_handle, "format", number_format_bound_format_thunk as *const u8, 1, ); if !format_fn.is_null() { crate::object::set_bound_native_closure_name(format_fn, ""); - set_internal_field( - obj, + set_internal_field_from_raw_handle( + &obj_handle, KEY_NF_BOUND_FORMAT, js_nanbox_pointer(format_fn as i64), ); } - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "formatToParts", number_format_bound_to_parts_thunk as *const u8, 1, @@ -1001,24 +1046,24 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // loses `this` and the `this_intl_object` guard throws a TypeError // (formatRange/invoked-as-func.js), matching the non-bound prototype // method these shadow. - install_function( - obj, + install_function_from_handle( + &obj_handle, "formatRange", number_format_range_thunk as *const u8, 2, 2, false, ); - install_function( - obj, + install_function_from_handle( + &obj_handle, "formatRangeToParts", number_format_range_to_parts_thunk as *const u8, 2, 2, false, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "resolvedOptions", number_format_bound_resolved_options_thunk as *const u8, 0, @@ -1031,7 +1076,7 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // with no DateTimeFormat-relevant properties, i.e. behave as empty — // `object_ptr_from_value` already returns `None` for them, so option // reads simply see `undefined`. - if JSValue::from_bits(options.to_bits()).is_null() { + if JSValue::from_bits(current_options().to_bits()).is_null() { throw_type_error("Cannot convert undefined or null to object"); } // GetOption reads run in the exact ECMA-402 CreateDateTimeFormat @@ -1039,18 +1084,20 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // localeMatcher / formatMatcher are validated but don't affect the // deterministic formatter, so their resolved value is discarded. let _ = enum_option( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", ); // `calendar` must match the Unicode locale `type` nonterminal; store // the canonicalized ID so `resolvedOptions().calendar` reflects it. - if let Some(calendar) = get_locale_extension_option(options, "calendar") { + if let Some(calendar) = get_locale_extension_option(current_options(), "calendar") { match canonicalize_calendar_id(&calendar) { - Some(canonical) => { - set_internal_field(obj, KEY_CALENDAR, string_value(&canonical)) - } + Some(canonical) => set_internal_field_from_raw_handle( + &obj_handle, + KEY_CALENDAR, + string_value(&canonical), + ), None => throw_range_error(&format!( "Value {calendar} out of range for Intl options property calendar" )), @@ -1061,30 +1108,35 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // then run ResolveLocale for `nu` — reconciling the option with the // locale's `-u-nu-` keyword so `resolvedOptions().locale` / // `.numberingSystem` reflect only the supported value actually used. - let dtf_opt_ns = get_locale_extension_option(options, "numberingSystem").map(|ns| { - if !is_well_formed_numbering_system(&ns) { - throw_range_error(&format!( - "Value {ns} out of range for Intl options property numberingSystem" - )); - } - ns.to_ascii_lowercase() - }); + let dtf_opt_ns = + get_locale_extension_option(current_options(), "numberingSystem").map(|ns| { + if !is_well_formed_numbering_system(&ns) { + throw_range_error(&format!( + "Value {ns} out of range for Intl options property numberingSystem" + )); + } + ns.to_ascii_lowercase() + }); let (dtf_locale, dtf_numbering) = resolve_numbering_system(&locale, dtf_opt_ns.as_deref()); - set_internal_field(obj, KEY_LOCALE, string_value(&dtf_locale)); - set_internal_field(obj, KEY_NUMBERING_SYSTEM, string_value(&dtf_numbering)); + set_internal_field_from_raw_handle(&obj_handle, KEY_LOCALE, string_value(&dtf_locale)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NUMBERING_SYSTEM, + string_value(&dtf_numbering), + ); // hour12 (boolean) then hourCycle (enum) — both only surface in // `resolvedOptions` when the resolved pattern has an hour field. - if let Some(h12) = get_bool_option(options, "hour12") { - set_internal_field(obj, KEY_HOUR12, bool_value(h12)); + if let Some(h12) = get_bool_option(current_options(), "hour12") { + set_internal_field_from_raw_handle(&obj_handle, KEY_HOUR12, bool_value(h12)); } - if let Some(hc) = get_option_string(options, "hourCycle") { + if let Some(hc) = get_option_string(current_options(), "hourCycle") { if !["h11", "h12", "h23", "h24"].contains(&hc.as_str()) { throw_range_error(&format!( "Value {hc} out of range for Intl options property hourCycle" )); } - set_internal_field(obj, KEY_HOUR_CYCLE, string_value(&hc)); + set_internal_field_from_raw_handle(&obj_handle, KEY_HOUR_CYCLE, string_value(&hc)); } // ECMA-402 DefaultTimeZone(): when no `timeZone` option is given, use // the HOST time zone (Node returns e.g. "Europe/Berlin"), not UTC — @@ -1093,8 +1145,12 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // single source of that logic (it canonicalizes offsets to `±HH:mm` // for FormatOffsetTimeZoneIdentifier and validates named zones // structurally, Perry having no tz database). - let time_zone = resolved_date_time_zone(options); - set_internal_field(obj, KEY_TIME_ZONE, string_value(&time_zone)); + let time_zone = resolved_date_time_zone(current_options()); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_TIME_ZONE, + string_value(&time_zone), + ); // Date/time component options (ECMA-402 Table 7), read in order. Each // out-of-range value is a RangeError. // @@ -1113,9 +1169,9 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // conflict check (step 35.b: throw if style + any component option). let mut any_component = false; let mut has_explicit_component = false; - let has_weekday = dt_component_option( - obj, - options, + let has_weekday = dt_component_option_from_handle( + &obj_handle, + current_options(), "weekday", &["narrow", "short", "long"], KEY_WEEKDAY, @@ -1123,58 +1179,89 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option any_component |= has_weekday; has_explicit_component |= has_weekday; // era counts toward the style-conflict check but NOT toward needDefaults. - has_explicit_component |= - dt_component_option(obj, options, "era", &["narrow", "short", "long"], KEY_ERA); - let has_year = - dt_component_option(obj, options, "year", &["2-digit", "numeric"], KEY_YEAR); + has_explicit_component |= dt_component_option_from_handle( + &obj_handle, + current_options(), + "era", + &["narrow", "short", "long"], + KEY_ERA, + ); + let has_year = dt_component_option_from_handle( + &obj_handle, + current_options(), + "year", + &["2-digit", "numeric"], + KEY_YEAR, + ); any_component |= has_year; has_explicit_component |= has_year; - let has_month = dt_component_option( - obj, - options, + let has_month = dt_component_option_from_handle( + &obj_handle, + current_options(), "month", &["2-digit", "numeric", "narrow", "short", "long"], KEY_MONTH, ); any_component |= has_month; has_explicit_component |= has_month; - let has_day = - dt_component_option(obj, options, "day", &["2-digit", "numeric"], KEY_DAY); + let has_day = dt_component_option_from_handle( + &obj_handle, + current_options(), + "day", + &["2-digit", "numeric"], + KEY_DAY, + ); any_component |= has_day; has_explicit_component |= has_day; - let has_day_period = dt_component_option( - obj, - options, + let has_day_period = dt_component_option_from_handle( + &obj_handle, + current_options(), "dayPeriod", &["narrow", "short", "long"], KEY_DAY_PERIOD, ); any_component |= has_day_period; has_explicit_component |= has_day_period; - let has_hour = - dt_component_option(obj, options, "hour", &["2-digit", "numeric"], KEY_HOUR); + let has_hour = dt_component_option_from_handle( + &obj_handle, + current_options(), + "hour", + &["2-digit", "numeric"], + KEY_HOUR, + ); any_component |= has_hour; has_explicit_component |= has_hour; - let has_minute = - dt_component_option(obj, options, "minute", &["2-digit", "numeric"], KEY_MINUTE); + let has_minute = dt_component_option_from_handle( + &obj_handle, + current_options(), + "minute", + &["2-digit", "numeric"], + KEY_MINUTE, + ); any_component |= has_minute; has_explicit_component |= has_minute; - let has_second = - dt_component_option(obj, options, "second", &["2-digit", "numeric"], KEY_SECOND); + let has_second = dt_component_option_from_handle( + &obj_handle, + current_options(), + "second", + &["2-digit", "numeric"], + KEY_SECOND, + ); any_component |= has_second; has_explicit_component |= has_second; // fractionalSecondDigits is GetNumberOption(1, 3) — out of range or // non-numeric is a RangeError. - if let Some(n) = get_number_option_coerced(options, "fractionalSecondDigits", 1.0, 3.0) + if let Some(n) = + get_number_option_coerced(current_options(), "fractionalSecondDigits", 1.0, 3.0) { - set_internal_field(obj, KEY_FRACTIONAL, n); + set_internal_field_from_raw_handle(&obj_handle, KEY_FRACTIONAL, n); any_component = true; has_explicit_component = true; } // timeZoneName counts toward the style-conflict check but NOT toward needDefaults. - has_explicit_component |= dt_component_option( - obj, - options, + has_explicit_component |= dt_component_option_from_handle( + &obj_handle, + current_options(), "timeZoneName", &[ "short", @@ -1186,10 +1273,15 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option ], KEY_TIME_ZONE_NAME, ); - let _ = enum_option(options, "formatMatcher", &["basic", "best fit"], "best fit"); + let _ = enum_option( + current_options(), + "formatMatcher", + &["basic", "best fit"], + "best fit", + ); // dateStyle / timeStyle have no default (an absent style stays absent // in `resolvedOptions`); an out-of-range value is a RangeError. - let date_style = get_option_string(options, "dateStyle"); + let date_style = get_option_string(current_options(), "dateStyle"); if let Some(ref ds) = date_style { if !["full", "long", "medium", "short"].contains(&ds.as_str()) { throw_range_error(&format!( @@ -1197,7 +1289,7 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option )); } } - let time_style = get_option_string(options, "timeStyle"); + let time_style = get_option_string(current_options(), "timeStyle"); if let Some(ref ts) = time_style { if !["full", "long", "medium", "short"].contains(&ts.as_str()) { throw_range_error(&format!( @@ -1214,54 +1306,58 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option ); } if let Some(ds) = date_style { - set_internal_field(obj, KEY_DATE_STYLE, string_value(&ds)); + set_internal_field_from_raw_handle(&obj_handle, KEY_DATE_STYLE, string_value(&ds)); } if let Some(ts) = time_style { - set_internal_field(obj, KEY_TIME_STYLE, string_value(&ts)); + set_internal_field_from_raw_handle(&obj_handle, KEY_TIME_STYLE, string_value(&ts)); } // ToDateTimeOptions(required="any", defaults="date"): when neither a // style nor any component was requested, fall back to numeric // year/month/day so `resolvedOptions` reports the default date shape. if !has_style && !any_component { - set_internal_field(obj, KEY_YEAR, string_value("numeric")); - set_internal_field(obj, KEY_MONTH, string_value("numeric")); - set_internal_field(obj, KEY_DAY, string_value("numeric")); - set_internal_field(obj, KEY_DT_IS_DEFAULT, bool_value(true)); + set_internal_field_from_raw_handle(&obj_handle, KEY_YEAR, string_value("numeric")); + set_internal_field_from_raw_handle(&obj_handle, KEY_MONTH, string_value("numeric")); + set_internal_field_from_raw_handle(&obj_handle, KEY_DAY, string_value("numeric")); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_DT_IS_DEFAULT, + bool_value(true), + ); } - let format_fn = install_bound_instance_function( - obj, + let format_fn = install_bound_instance_function_from_handle( + &obj_handle, "format", date_time_format_bound_format_thunk as *const u8, 1, ); if !format_fn.is_null() { crate::object::set_bound_native_closure_name(format_fn, ""); - set_internal_field( - obj, + set_internal_field_from_raw_handle( + &obj_handle, KEY_DTF_BOUND_FORMAT, js_nanbox_pointer(format_fn as i64), ); } - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "formatToParts", date_time_format_bound_to_parts_thunk as *const u8, 1, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "formatRange", date_time_format_bound_range_thunk as *const u8, 2, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "formatRangeToParts", date_time_format_bound_range_to_parts_thunk as *const u8, 2, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "resolvedOptions", date_time_format_bound_resolved_options_thunk as *const u8, 0, @@ -1272,10 +1368,10 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // TypeError) then GetOption in this exact order: usage, localeMatcher, // collation, numeric, caseFirst, sensitivity, ignorePunctuation // (constructor-options-throwing-getters / resolvedOptions order.js). - let options = coerce_options_reject_null(options); - let usage = enum_option_strict(options, "usage", &["sort", "search"], "sort"); + let _ = coerce_options_reject_null(current_options()); + let usage = enum_option_strict(current_options(), "usage", &["sort", "search"], "sort"); let _ = enum_option_strict( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", @@ -1284,71 +1380,79 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // /`search` values, are a RangeError (the latter are only valid as a // `usage` selector, never an explicit collation). A valid value wins // over any `-u-co-` keyword; absent ⇒ fall back to the extension. - let collation_opt = get_option_string_coerced(options, "collation").map(|v| { - if !is_well_formed_numbering_system(&v) || v == "standard" || v == "search" { - throw_range_error(&format!( - "Value {v} out of range for Intl options property collation" - )); - } - v - }); - let numeric_opt = get_bool_option(options, "numeric"); - let case_first_opt = get_option_string_coerced(options, "caseFirst").map(|v| { - if ["upper", "lower", "false"].contains(&v.as_str()) { + let collation_opt = + get_option_string_coerced(current_options(), "collation").map(|v| { + if !is_well_formed_numbering_system(&v) || v == "standard" || v == "search" { + throw_range_error(&format!( + "Value {v} out of range for Intl options property collation" + )); + } v - } else { - throw_range_error(&format!( - "Value {v} out of range for Intl options property caseFirst" - )) - } - }); + }); + let numeric_opt = get_bool_option(current_options(), "numeric"); + let case_first_opt = + get_option_string_coerced(current_options(), "caseFirst").map(|v| { + if ["upper", "lower", "false"].contains(&v.as_str()) { + v + } else { + throw_range_error(&format!( + "Value {v} out of range for Intl options property caseFirst" + )) + } + }); let sensitivity = enum_option_strict( - options, + current_options(), "sensitivity", &["base", "accent", "case", "variant"], "variant", ); - let ignore_punct = get_bool_option(options, "ignorePunctuation").unwrap_or(false); - // ResolveLocale: when an option is absent, fall back to the matching - // Unicode (`-u-`) extension keyword in the resolved locale — `kn` - // (numeric, value-less ⇒ true) and `kf` (caseFirst). - let numeric = - numeric_opt.unwrap_or_else(|| match unicode_extension_keyword(&locale, "kn") { - Some(v) => v != "false", - None => false, - }); - let case_first = case_first_opt.unwrap_or_else(|| { - unicode_extension_keyword(&locale, "kf") - .filter(|v| ["upper", "lower", "false"].contains(&v.as_str())) - .unwrap_or_else(|| "false".to_string()) - }); - let collation = collation_opt.unwrap_or_else(|| { - unicode_extension_keyword(&locale, "co") - .filter(|v| !v.is_empty() && v != "standard" && v != "search") - .unwrap_or_else(|| "default".to_string()) - }); - set_internal_field(obj, KEY_COL_USAGE, string_value(&usage)); - set_internal_field(obj, KEY_COL_SENSITIVITY, string_value(&sensitivity)); - set_internal_field(obj, KEY_COL_IGNORE_PUNCT, bool_value(ignore_punct)); - set_internal_field(obj, KEY_COL_COLLATION, string_value(&collation)); - set_internal_field(obj, KEY_COL_NUMERIC, bool_value(numeric)); - set_internal_field(obj, KEY_COL_CASE_FIRST, string_value(&case_first)); - let compare_fn = install_bound_instance_function( - obj, + let ignore_punct = get_bool_option(current_options(), "ignorePunctuation") + .unwrap_or_else(|| locale == "th" || locale.starts_with("th-")); + let (resolved_locale, collation, numeric, case_first) = + resolve_collator_locale(&locale, collation_opt, numeric_opt, case_first_opt); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_LOCALE, + string_value(&resolved_locale), + ); + set_internal_field_from_raw_handle(&obj_handle, KEY_COL_USAGE, string_value(&usage)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_COL_SENSITIVITY, + string_value(&sensitivity), + ); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_COL_IGNORE_PUNCT, + bool_value(ignore_punct), + ); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_COL_COLLATION, + string_value(&collation), + ); + set_internal_field_from_raw_handle(&obj_handle, KEY_COL_NUMERIC, bool_value(numeric)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_COL_CASE_FIRST, + string_value(&case_first), + ); + let compare_fn = install_bound_instance_function_from_handle( + &obj_handle, "compare", collator_bound_compare_thunk as *const u8, 2, ); if !compare_fn.is_null() { crate::object::set_bound_native_closure_name(compare_fn, ""); - set_internal_field( - obj, + set_internal_field_from_raw_handle( + &obj_handle, KEY_COL_BOUND_COMPARE, js_nanbox_pointer(compare_fn as i64), ); } - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "resolvedOptions", collator_bound_resolved_options_thunk as *const u8, 0, @@ -1357,24 +1461,28 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option KIND_SEGMENTER => { // `? ToObject(options)` (null → TypeError), then GetOption in order: // localeMatcher, granularity (options-order.js / options-null.js). - let options = coerce_options_reject_null(options); + let _ = coerce_options_reject_null(current_options()); let _ = enum_option_strict( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", ); let granularity = - normalize_granularity(get_option_string_coerced(options, "granularity")); - set_internal_field(obj, KEY_GRANULARITY, string_value(&granularity)); - install_bound_instance_function( - obj, + normalize_granularity(get_option_string_coerced(current_options(), "granularity")); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_GRANULARITY, + string_value(&granularity), + ); + install_bound_instance_function_from_handle( + &obj_handle, "segment", segmenter_bound_segment_thunk as *const u8, 1, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "resolvedOptions", segmenter_bound_resolved_options_thunk as *const u8, 0, @@ -1384,36 +1492,41 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option // `? GetOptionsObject(options)` (any non-Object, non-undefined → // TypeError), then GetOption: localeMatcher, type, style // (options-getoptionsobject.js / options-order.js). - let options = get_options_object(options); + let _ = get_options_object(current_options()); let _ = enum_option_strict( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", ); let list_type = enum_option_strict( - options, + current_options(), "type", &["conjunction", "disjunction", "unit"], "conjunction", ); - let style = enum_option_strict(options, "style", &["long", "short", "narrow"], "long"); - set_internal_field(obj, KEY_TYPE, string_value(&list_type)); - set_internal_field(obj, KEY_LF_STYLE, string_value(&style)); - install_bound_instance_function( - obj, + let style = enum_option_strict( + current_options(), + "style", + &["long", "short", "narrow"], + "long", + ); + set_internal_field_from_raw_handle(&obj_handle, KEY_TYPE, string_value(&list_type)); + set_internal_field_from_raw_handle(&obj_handle, KEY_LF_STYLE, string_value(&style)); + install_bound_instance_function_from_handle( + &obj_handle, "format", list_format_bound_format_thunk as *const u8, 1, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "formatToParts", list_format_bound_to_parts_thunk as *const u8, 1, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "resolvedOptions", list_format_bound_resolved_options_thunk as *const u8, 0, @@ -1422,165 +1535,104 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option KIND_RELATIVE_TIME => { // `? ToObject(options)` (null → TypeError), then GetOption in order: // localeMatcher, numberingSystem, style, numeric (options-order.js). - let options = coerce_options_reject_null(options); + options_handle.set_nanbox_f64(to_object_for_options(coerce_options_reject_null( + current_options(), + ))); let _ = enum_option_strict( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", ); - if let Some(ns) = get_option_string_coerced(options, "numberingSystem") { - if !is_well_formed_numbering_system(&ns) { - throw_range_error(&format!( - "Value {ns} out of range for Intl options property numberingSystem" - )); + let opt_ns = match get_option_string_coerced(current_options(), "numberingSystem") { + Some(ns) => { + let lower = ns.to_ascii_lowercase(); + if !is_well_formed_numbering_system(&lower) { + throw_range_error(&format!( + "Value {ns} out of range for Intl options property numberingSystem" + )); + } + Some(lower) } - } - let style = enum_option_strict(options, "style", &["long", "short", "narrow"], "long"); - let numeric = enum_option_strict(options, "numeric", &["always", "auto"], "always"); - set_internal_field(obj, KEY_RTF_STYLE, string_value(&style)); - set_internal_field(obj, KEY_NUMERIC, string_value(&numeric)); - install_bound_instance_function(obj, "format", rtf_bound_format_thunk as *const u8, 2); - install_bound_instance_function( - obj, + None => None, + }; + let (resolved_locale, numbering) = resolve_numbering_system(&locale, opt_ns.as_deref()); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_LOCALE, + string_value(&resolved_locale), + ); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_RTF_NUMBERING, + string_value(&numbering), + ); + let style = enum_option_strict( + current_options(), + "style", + &["long", "short", "narrow"], + "long", + ); + let numeric = + enum_option_strict(current_options(), "numeric", &["always", "auto"], "always"); + set_internal_field_from_raw_handle(&obj_handle, KEY_RTF_STYLE, string_value(&style)); + set_internal_field_from_raw_handle(&obj_handle, KEY_NUMERIC, string_value(&numeric)); + install_bound_instance_function_from_handle( + &obj_handle, + "format", + rtf_bound_format_thunk as *const u8, + 2, + ); + install_bound_instance_function_from_handle( + &obj_handle, "formatToParts", rtf_bound_to_parts_thunk as *const u8, 2, ); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle( + &obj_handle, "resolvedOptions", rtf_bound_resolved_options_thunk as *const u8, 0, ); } KIND_PLURAL_RULES => { - // `? GetOptionsObject(options)`, then GetOption in the exact order - // constructor-option-read-order.js asserts: localeMatcher, type, - // notation, compactDisplay, then SetNumberFormatDigitOptions - // (minimumIntegerDigits, min/maxFractionDigits, min/maxSignificantDigits, - // roundingIncrement, roundingMode, roundingPriority, trailingZeroDisplay). - let options = get_options_object(options); - let _ = enum_option_strict( - options, - "localeMatcher", - &["lookup", "best fit"], - "best fit", - ); - let pr_type = enum_option_strict(options, "type", &["cardinal", "ordinal"], "cardinal"); - set_internal_field(obj, KEY_TYPE, string_value(&pr_type)); - let notation = enum_option_strict( - options, - "notation", - &["standard", "scientific", "engineering", "compact"], - "standard", - ); - let compact_display = - enum_option_strict(options, "compactDisplay", &["short", "long"], "short"); - set_internal_field(obj, KEY_PR_NOTATION, string_value(¬ation)); - if notation == "compact" { - set_internal_field(obj, KEY_PR_COMPACT_DISPLAY, string_value(&compact_display)); - } - let min_int = get_option_number(options, "minimumIntegerDigits").unwrap_or(1.0); - set_internal_field(obj, KEY_PR_MIN_INT, min_int); - let min_frac_read = get_option_number(options, "minimumFractionDigits"); - let max_frac_read = get_option_number(options, "maximumFractionDigits"); - let min_sig = get_option_number(options, "minimumSignificantDigits"); - let max_sig = get_option_number(options, "maximumSignificantDigits"); - // Trailing SetNumberFormatDigitOptions reads — observed for read-order - // parity even though Perry's plural selection ignores their values. - let _ = get_option_value(options, "roundingIncrement"); - let _ = get_option_value(options, "roundingMode"); - let _ = get_option_value(options, "roundingPriority"); - let _ = get_option_value(options, "trailingZeroDisplay"); - if min_sig.is_some() || max_sig.is_some() { - set_internal_field(obj, KEY_PR_USE_SIG, bool_value(true)); - set_internal_field(obj, KEY_PR_MIN_SIG, min_sig.unwrap_or(1.0)); - set_internal_field(obj, KEY_PR_MAX_SIG, max_sig.unwrap_or(21.0)); - } else { - set_internal_field(obj, KEY_PR_USE_SIG, bool_value(false)); - // Reuse the values read above (in spec order) — re-reading would - // double-invoke the option getters and break read-order parity. - let min_frac = min_frac_read.unwrap_or(0.0); - let max_frac = max_frac_read.unwrap_or_else(|| min_frac.max(3.0)); - set_internal_field(obj, KEY_PR_MIN_FRAC, min_frac); - set_internal_field(obj, KEY_PR_MAX_FRAC, max_frac); - } - install_bound_instance_function( - obj, - "select", - plural_rules_bound_select_thunk as *const u8, - 1, - ); - install_bound_instance_function( - obj, - "selectRange", - plural_rules_bound_select_range_thunk as *const u8, - 2, - ); - install_bound_instance_function( - obj, - "resolvedOptions", - plural_rules_bound_resolved_options_thunk as *const u8, - 0, - ); + let obj = obj_handle.with_mut_ptr(|obj| { + list_relative_plural::configure_plural_rules(obj, &options_handle) + }); + obj_handle.set_raw_mut_ptr(obj); + } + KIND_DURATION_FORMAT => { + obj_handle.with_mut_ptr(|obj| duration_format::configure(obj, current_options())) + } + KIND_DISPLAY_NAMES => { + obj_handle.with_mut_ptr(|obj| display_names::configure(obj, current_options())) } - KIND_DURATION_FORMAT => duration_format::configure(obj, options), - KIND_DISPLAY_NAMES => display_names::configure(obj, options), _ => {} } - let proto = constructor_target_prototype(closure); + let proto = closure_handle.with_const_ptr(constructor_target_prototype); if JSValue::from_bits(proto.to_bits()).is_pointer() { - crate::object::prototype_chain::object_set_static_prototype(obj as usize, proto.to_bits()); - } - let instance = js_nanbox_pointer(obj as i64); + obj_handle.with_mut_ptr(|obj: *mut ObjectHeader| { + crate::object::prototype_chain::object_set_static_prototype( + obj as usize, + proto.to_bits(), + ) + }); + } + let instance = obj_handle.with_mut_ptr(|obj: *mut ObjectHeader| js_nanbox_pointer(obj as i64)); // ChainNumberFormat / ChainDateTimeFormat only (see `chain_legacy_constructed`): // Intl.Collator ignores its this-value, so it is deliberately excluded. if matches!(kind, KIND_NUMBER | KIND_DATE_TIME) { - if let Some(this_value) = ctor_guard::chain_legacy_constructed(closure, instance) { + if let Some(this_value) = closure_handle + .with_const_ptr(|closure| ctor_guard::chain_legacy_constructed(closure, instance)) + { return this_value; } } instance } -fn install_bound_instance_function( - obj: *mut ObjectHeader, - name: &str, - func_ptr: *const u8, - arity: u32, -) -> *mut ClosureHeader { - let closure = crate::closure::js_closure_alloc(func_ptr, 1); - if closure.is_null() { - return closure; - } - crate::closure::js_register_closure_arity(func_ptr, arity); - crate::closure::js_closure_set_capture_f64(closure, 0, js_nanbox_pointer(obj as i64)); - crate::object::set_bound_native_closure_name(closure, name); - crate::object::set_builtin_closure_length(closure as usize, arity); - // A bound Intl instance method (`nf.format`, `nf.resolvedOptions`, …) is a - // built-in non-constructor function: it has NO `[[Construct]]` and therefore - // no own `prototype` property (ECMA-262 §17 — built-in functions that aren't - // constructors don't get the auto-created `.prototype`). Flag it so - // `function_would_have_own_prototype` / the `new` path treat it like any - // other builtin (`Math.max`), matching `format-function-builtin.js`. - crate::object::set_builtin_closure_non_constructable(closure as usize); - crate::object::set_builtin_property_attrs( - closure as usize, - "name".to_string(), - PropertyAttrs::new(false, false, true), - ); - crate::object::set_builtin_property_attrs( - closure as usize, - "length".to_string(), - PropertyAttrs::new(false, false, true), - ); - set_field(obj, name, js_nanbox_pointer(closure as i64)); - set_builtin_attrs(obj, name, PropertyAttrs::new(true, false, true)); - closure -} - pub(super) extern "C" fn number_format_constructor_thunk( closure: *const ClosureHeader, rest: f64, @@ -1696,48 +1748,6 @@ extern "C" fn supported_locales_of_thunk(_closure: *const ClosureHeader, rest: f supported_locales_array(rest_arg(rest, 0), rest_arg(rest, 1)) } -fn install_function( - owner: *mut ObjectHeader, - name: &str, - func_ptr: *const u8, - call_arity: u32, - length: u32, - has_rest: bool, -) -> f64 { - let closure = crate::closure::js_closure_alloc(func_ptr, 0); - if closure.is_null() { - return undefined(); - } - if has_rest { - crate::closure::js_register_closure_rest(func_ptr, call_arity); - } else { - crate::closure::js_register_closure_arity(func_ptr, call_arity); - } - crate::object::set_bound_native_closure_name(closure, name); - crate::object::set_builtin_closure_length(closure as usize, length); - // Intl prototype methods (`formatToParts`, `resolvedOptions`, …), the static - // `supportedLocalesOf`, and the this-based instance methods - // (`formatRange`/`formatRangeToParts`) installed through here are all - // built-in non-constructor functions: no `[[Construct]]`, hence no own - // `prototype` property (`builtin.js` asserts `hasOwnProperty("prototype")` - // is false and `isConstructor` is false). Flag them like any other builtin. - crate::object::set_builtin_closure_non_constructable(closure as usize); - crate::object::set_builtin_property_attrs( - closure as usize, - "name".to_string(), - PropertyAttrs::new(false, false, true), - ); - crate::object::set_builtin_property_attrs( - closure as usize, - "length".to_string(), - PropertyAttrs::new(false, false, true), - ); - let value = js_nanbox_pointer(closure as i64); - set_field(owner, name, value); - set_builtin_attrs(owner, name, PropertyAttrs::new(true, false, true)); - value -} - /// Set `proto[Symbol.toStringTag]` to `tag` (non-writable, non-enumerable, /// configurable) so `Object.prototype.toString.call(instance)` yields /// `[object ]` — the ECMA-402 default for every `Intl.*` prototype. diff --git a/crates/perry-runtime/src/intl/canon_aliases.rs b/crates/perry-runtime/src/intl/canon_aliases.rs index 21d65f3a98..ed5fa484a7 100644 --- a/crates/perry-runtime/src/intl/canon_aliases.rs +++ b/crates/perry-runtime/src/intl/canon_aliases.rs @@ -11,10 +11,11 @@ //! unicode-ext-canonicalize-*`). //! //! We keep this to the curated, code-unit-`|uvalue|`-shaped deprecated type -//! aliases that test262 exercises for the `ca` / `ks` / `ms` / `rg` / `sd` / -//! `tz` keys. Only the exact single-value replacements are represented; the -//! multi-territory / likely-subtag-dependent territoryAlias logic is out of -//! scope here (it lives in the language/region path, not the `-u-` extension). +//! aliases that test262 exercises for the transformed `m0` key and the Unicode +//! `ca` / `ks` / `ms` / `rg` / `sd` / `tz` keys. Only the exact single-value +//! replacements are represented; the multi-territory / likely-subtag-dependent +//! territoryAlias logic is out of scope here (it lives in the language/region +//! path, not the extension type-value path). /// `(key, deprecated_value, canonical_value)` for the Unicode-extension type /// aliases test262 canonicalizes. `deprecated_value` is the full `-`-joined @@ -60,6 +61,70 @@ fn u_ext_type_alias(key: &str, value: &str) -> Option<&'static str> { }) } +/// Rewrite the deprecated transformed-extension `m0-names` type. CLDR maps it +/// to `m0-prprname`, but ICU4X 2.2 leaves it unchanged. A transformed key is an +/// ASCII letter followed by a digit; that distinction prevents a two-letter +/// region in the optional `tlang` prefix from being mistaken for a field key. +fn canonicalize_transformed_extension_types(tag: &str) -> String { + let subtags: Vec<&str> = tag.split('-').collect(); + let Some(t_start) = subtags.iter().position(|s| s.eq_ignore_ascii_case("t")) else { + return tag.to_string(); + }; + if subtags[..t_start] + .iter() + .any(|s| s.eq_ignore_ascii_case("x")) + { + return tag.to_string(); + } + let t_end = subtags + .iter() + .enumerate() + .skip(t_start + 1) + .find_map(|(i, s)| (s.len() == 1).then_some(i)) + .unwrap_or(subtags.len()); + let is_tkey = |s: &str| { + let bytes = s.as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1].is_ascii_digit() + }; + let mut out: Vec = Vec::with_capacity(subtags.len()); + out.extend(subtags[..=t_start].iter().map(|s| s.to_string())); + let mut changed = false; + let mut i = t_start + 1; + while i < t_end { + if !is_tkey(subtags[i]) { + out.push(subtags[i].to_string()); + i += 1; + continue; + } + let key = subtags[i]; + out.push(key.to_string()); + let value_start = i + 1; + let value_end = (value_start..t_end) + .find(|&j| is_tkey(subtags[j])) + .unwrap_or(t_end); + if key.eq_ignore_ascii_case("m0") + && value_end == value_start + 1 + && subtags[value_start].eq_ignore_ascii_case("names") + { + out.push("prprname".to_string()); + changed = true; + } else { + out.extend( + subtags[value_start..value_end] + .iter() + .map(|s| s.to_string()), + ); + } + i = value_end; + } + out.extend(subtags[t_end..].iter().map(|s| s.to_string())); + if changed { + out.join("-") + } else { + tag.to_string() + } +} + /// Rewrite deprecated CLDR type values inside the Unicode (`-u-`) extension of /// an already-`normalize`d BCP-47 tag. Returns the tag unchanged when it has no /// `-u-` extension or no aliased value. @@ -70,6 +135,8 @@ fn u_ext_type_alias(key: &str, value: &str) -> Option<&'static str> { /// run that follows it (its `-`-joined 3..8-char subtags) is what we match and /// replace. pub(super) fn canonicalize_unicode_extension_types(tag: &str) -> String { + let transformed = canonicalize_transformed_extension_types(tag); + let tag = transformed.as_str(); let subtags: Vec<&str> = tag.split('-').collect(); // Find the `u` singleton (not inside a private-use `x` sequence). let mut u_start = None; @@ -132,3 +199,31 @@ pub(super) fn canonicalize_unicode_extension_types(tag: &str) -> String { tag.to_string() } } + +#[cfg(test)] +mod tests { + use super::canonicalize_unicode_extension_types; + + #[test] + fn canonicalizes_transformed_type_without_mistaking_region_for_key() { + assert_eq!( + canonicalize_unicode_extension_types("und-Latn-t-und-hani-m0-names"), + "und-Latn-t-und-hani-m0-prprname" + ); + assert_eq!( + canonicalize_unicode_extension_types("en-t-en-US-m0-names"), + "en-t-en-US-m0-prprname" + ); + assert_eq!( + canonicalize_unicode_extension_types("en-T-en-US-M0-NAMES"), + "en-T-en-US-M0-prprname" + ); + assert_eq!( + canonicalize_unicode_extension_types("en-t-en-m0-names-u-ca-gregory"), + "en-t-en-m0-prprname-u-ca-gregory" + ); + for unchanged in ["en-x-t-m0-names", "en-t-en-h0-hybrid"] { + assert_eq!(canonicalize_unicode_extension_types(unchanged), unchanged); + } + } +} diff --git a/crates/perry-runtime/src/intl/date_collator.rs b/crates/perry-runtime/src/intl/date_collator.rs index a2e3cfab78..6fd39575ee 100644 --- a/crates/perry-runtime/src/intl/date_collator.rs +++ b/crates/perry-runtime/src/intl/date_collator.rs @@ -5,6 +5,9 @@ use crate::closure::ClosureHeader; use crate::object::{js_object_alloc, ObjectHeader}; use crate::value::{js_nanbox_pointer, JSValue}; +mod compare; +use compare::{collator_compare_order, CollatorCompareOptions}; + /// ECMA-402 FormatDateTime / HandleDateTimeValue step 1: coerce the /// `format`/`formatToParts` argument to a TimeClip'd integer-millisecond value. /// `undefined` means "now". Every other value goes through ToNumber — a Date @@ -1603,22 +1606,123 @@ fn collation_normalize(s: &str) -> String { s.to_string() } -pub(crate) fn compare_strings(locale: &str, left: &str, right: &str) -> f64 { - let left = collation_normalize(left); - let right = collation_normalize(right); - let (left, right) = (left.as_str(), right.as_str()); - let ordering = if locale == "sv" || locale.starts_with("sv-") { - swedish_collation_key(left).cmp(&swedish_collation_key(right)) +fn locale_base_name(locale: &str) -> String { + locale + .split('-') + .take_while(|part| part.len() != 1) + .collect::>() + .join("-") +} + +fn supports_collation(locale: &str, collation: &str) -> bool { + collation == "eor" || (collation == "phonebk" && (locale == "de" || locale.starts_with("de-"))) +} + +/// Resolve Collator's relevant Unicode extension keys. Unsupported/irrelevant +/// keys and attributes are removed from the resolved locale; an explicit +/// supported option overrides the extension, while an unsupported option does +/// not displace a supported extension value. +pub(crate) fn resolve_collator_locale( + requested: &str, + collation_option: Option, + numeric_option: Option, + case_first_option: Option, +) -> (String, String, bool, String) { + let base = locale_base_name(requested); + let ext_collation = + unicode_extension_keyword(requested, "co").filter(|value| supports_collation(&base, value)); + let effective_collation = collation_option + .filter(|value| supports_collation(&base, value)) + .or_else(|| ext_collation.clone()) + .unwrap_or_else(|| "default".to_string()); + + let ext_numeric = + unicode_extension_keyword(requested, "kn").and_then(|value| match value.as_str() { + "" | "true" => Some(true), + "false" => Some(false), + _ => None, + }); + let effective_numeric = numeric_option.or(ext_numeric).unwrap_or(false); + let ext_case_first = unicode_extension_keyword(requested, "kf") + .filter(|value| ["upper", "lower", "false"].contains(&value.as_str())); + let effective_case_first = case_first_option + .clone() + .or_else(|| ext_case_first.clone()) + .unwrap_or_else(|| "false".to_string()); + + let mut keywords: Vec<(&str, String)> = Vec::new(); + if ext_collation.as_deref() == Some(effective_collation.as_str()) { + keywords.push(("co", effective_collation.clone())); + } + if ext_case_first.as_deref() == Some(effective_case_first.as_str()) { + keywords.push(("kf", effective_case_first.clone())); + } + if ext_numeric == Some(effective_numeric) { + keywords.push(( + "kn", + if effective_numeric { "" } else { "false" }.to_string(), + )); + } + let resolved_locale = if keywords.is_empty() { + base } else { - left.cmp(right) + let mut locale = format!("{base}-u"); + for (key, value) in keywords { + locale.push('-'); + locale.push_str(key); + if !value.is_empty() { + locale.push('-'); + locale.push_str(&value); + } + } + locale }; - match ordering { - std::cmp::Ordering::Less => -1.0, - std::cmp::Ordering::Equal => 0.0, - std::cmp::Ordering::Greater => 1.0, + ( + resolved_locale, + effective_collation, + effective_numeric, + effective_case_first, + ) +} + +#[cfg(feature = "string-normalize")] +fn base_collation_key(s: &str, preserve_case: bool) -> String { + use unicode_normalization::{char::is_combining_mark, UnicodeNormalization}; + s.nfd() + .filter(|ch| !is_combining_mark(*ch)) + .flat_map(|ch| { + if preserve_case { + ch.to_string().chars().collect::>() + } else { + ch.to_lowercase().collect::>() + } + }) + .collect() +} + +#[cfg(not(feature = "string-normalize"))] +fn base_collation_key(s: &str, preserve_case: bool) -> String { + if preserve_case { + s.to_string() + } else { + s.to_lowercase() } } +fn german_phonebook_key(s: &str) -> String { + let mut out = String::new(); + for ch in collation_normalize(s).chars() { + match ch.to_lowercase().next().unwrap_or(ch) { + 'ä' => out.push_str("ae"), + 'ö' => out.push_str("oe"), + 'ü' => out.push_str("ue"), + 'ß' => out.push_str("ss"), + other => out.push_str(&base_collation_key(&other.to_string(), false)), + } + } + out +} + /// `GetOption(options, key, "string", allowed, undefined)` for a Collator string /// option: only `undefined` selects the default (absent); every other value — /// `null` included — is coerced via `ToString` and rejected with a RangeError @@ -1710,14 +1814,42 @@ fn is_punctuation(c: char) -> bool { } pub(crate) fn collator_compare_object(obj: *const ObjectHeader, left: f64, right: f64) -> f64 { - let locale = get_string_field(obj, KEY_LOCALE).unwrap_or_else(|| "en-US".to_string()); - let ignore_punct = get_field(obj, KEY_COL_IGNORE_PUNCT).to_bits() == crate::value::TAG_TRUE; - let (mut l, mut r) = (value_to_string(left), value_to_string(right)); - if ignore_punct { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_const_ptr(obj); + let left = scope.root_nanbox_f64(left); + let right = scope.root_nanbox_f64(right); + + // Snapshot every immutable collator slot before ToString can invoke user + // code and move the instance. Re-read the rooted object for every access. + let options = CollatorCompareOptions { + locale: get_string_field_from_raw_handle(&obj, KEY_LOCALE) + .unwrap_or_else(|| "en-US".to_string()), + usage: get_string_field_from_raw_handle(&obj, KEY_COL_USAGE) + .unwrap_or_else(|| "sort".to_string()), + collation: get_string_field_from_raw_handle(&obj, KEY_COL_COLLATION) + .unwrap_or_else(|| "default".to_string()), + sensitivity: get_string_field_from_raw_handle(&obj, KEY_COL_SENSITIVITY) + .unwrap_or_else(|| "variant".to_string()), + numeric: get_field_from_raw_handle(&obj, KEY_COL_NUMERIC).to_bits() + == crate::value::TAG_TRUE, + case_first: get_string_field_from_raw_handle(&obj, KEY_COL_CASE_FIRST) + .unwrap_or_else(|| "false".to_string()), + ignore_punctuation: get_field_from_raw_handle(&obj, KEY_COL_IGNORE_PUNCT).to_bits() + == crate::value::TAG_TRUE, + }; + let (mut l, mut r) = ( + value_to_string(left.get_nanbox_f64()), + value_to_string(right.get_nanbox_f64()), + ); + if options.ignore_punctuation { l = strip_ignorable_punctuation(&l); r = strip_ignorable_punctuation(&r); } - compare_strings(&locale, &l, &r) + match collator_compare_order(&options, &l, &r) { + std::cmp::Ordering::Less => -1.0, + std::cmp::Ordering::Equal => 0.0, + std::cmp::Ordering::Greater => 1.0, + } } pub(crate) extern "C" fn collator_resolved_options_thunk(_closure: *const ClosureHeader) -> f64 { @@ -1779,3 +1911,48 @@ pub(crate) fn collator_resolved_options_object(obj: *const ObjectHeader) -> f64 ); js_nanbox_pointer(out as i64) } + +#[cfg(test)] +mod collator_compare_tests { + use super::*; + + fn options(numeric: bool, case_first: &str) -> CollatorCompareOptions { + CollatorCompareOptions { + locale: "en".to_string(), + usage: "sort".to_string(), + collation: "default".to_string(), + sensitivity: "variant".to_string(), + numeric, + case_first: case_first.to_string(), + ignore_punctuation: false, + } + } + + #[test] + fn numeric_option_compares_digit_runs_by_value() { + assert_eq!( + collator_compare_order(&options(false, "false"), "10", "9"), + std::cmp::Ordering::Less + ); + assert_eq!( + collator_compare_order(&options(true, "false"), "10", "9"), + std::cmp::Ordering::Greater + ); + assert_eq!( + collator_compare_order(&options(true, "false"), "2", "02"), + std::cmp::Ordering::Equal + ); + } + + #[test] + fn case_first_option_changes_case_tie_breaking() { + assert_eq!( + collator_compare_order(&options(false, "upper"), "A", "a"), + std::cmp::Ordering::Less + ); + assert_eq!( + collator_compare_order(&options(false, "lower"), "A", "a"), + std::cmp::Ordering::Greater + ); + } +} diff --git a/crates/perry-runtime/src/intl/date_collator/compare.rs b/crates/perry-runtime/src/intl/date_collator/compare.rs new file mode 100644 index 0000000000..86777b425f --- /dev/null +++ b/crates/perry-runtime/src/intl/date_collator/compare.rs @@ -0,0 +1,148 @@ +use super::*; + +pub(super) struct CollatorCompareOptions { + pub(super) locale: String, + pub(super) usage: String, + pub(super) collation: String, + pub(super) sensitivity: String, + pub(super) numeric: bool, + pub(super) case_first: String, + pub(super) ignore_punctuation: bool, +} + +fn compare_digit_runs(left: &str, right: &str) -> std::cmp::Ordering { + let (left, right) = (left.as_bytes(), right.as_bytes()); + let (mut li, mut ri) = (0usize, 0usize); + while li < left.len() && ri < right.len() { + if left[li].is_ascii_digit() && right[ri].is_ascii_digit() { + let left_end = (li..left.len()) + .find(|&i| !left[i].is_ascii_digit()) + .unwrap_or(left.len()); + let right_end = (ri..right.len()) + .find(|&i| !right[i].is_ascii_digit()) + .unwrap_or(right.len()); + let left_significant = (li..left_end) + .find(|&i| left[i] != b'0') + .unwrap_or(left_end); + let right_significant = (ri..right_end) + .find(|&i| right[i] != b'0') + .unwrap_or(right_end); + let length_order = (left_end - left_significant).cmp(&(right_end - right_significant)); + if length_order != std::cmp::Ordering::Equal { + return length_order; + } + let value_order = + left[left_significant..left_end].cmp(&right[right_significant..right_end]); + if value_order != std::cmp::Ordering::Equal { + return value_order; + } + li = left_end; + ri = right_end; + continue; + } + + let left_char = std::str::from_utf8(&left[li..]) + .expect("collation key is UTF-8") + .chars() + .next() + .expect("left key is not exhausted"); + let right_char = std::str::from_utf8(&right[ri..]) + .expect("collation key is UTF-8") + .chars() + .next() + .expect("right key is not exhausted"); + let order = left_char.cmp(&right_char); + if order != std::cmp::Ordering::Equal { + return order; + } + li += left_char.len_utf8(); + ri += right_char.len_utf8(); + } + (left.len() - li).cmp(&(right.len() - ri)) +} + +fn compare_collation_keys(left: &str, right: &str, numeric: bool) -> std::cmp::Ordering { + if numeric { + compare_digit_runs(left, right) + } else { + left.cmp(right) + } +} + +fn case_first_order(left: &str, right: &str, case_first: &str) -> std::cmp::Ordering { + if !matches!(case_first, "upper" | "lower") { + return std::cmp::Ordering::Equal; + } + for (left, right) in left.chars().zip(right.chars()) { + if left == right || left.to_lowercase().to_string() != right.to_lowercase().to_string() { + continue; + } + let left_upper = left.is_uppercase(); + let right_upper = right.is_uppercase(); + if left_upper != right_upper { + let upper_first = case_first == "upper"; + return if left_upper == upper_first { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + }; + } + } + std::cmp::Ordering::Equal +} + +pub(super) fn collator_compare_order( + options: &CollatorCompareOptions, + left: &str, + right: &str, +) -> std::cmp::Ordering { + let swedish = options.locale == "sv" || options.locale.starts_with("sv-"); + let phonebook = options.collation == "phonebk" + || (options.usage == "search" + && (options.locale == "de" || options.locale.starts_with("de-"))); + let primary_left = if swedish { + swedish_collation_key(left) + .into_iter() + .filter_map(char::from_u32) + .collect() + } else if phonebook { + german_phonebook_key(left) + } else { + base_collation_key(left, false) + }; + let primary_right = if swedish { + swedish_collation_key(right) + .into_iter() + .filter_map(char::from_u32) + .collect() + } else if phonebook { + german_phonebook_key(right) + } else { + base_collation_key(right, false) + }; + let primary = compare_collation_keys(&primary_left, &primary_right, options.numeric); + if primary != std::cmp::Ordering::Equal || options.sensitivity == "base" { + return primary; + } + let normalized_left = collation_normalize(left); + let normalized_right = collation_normalize(right); + if options.sensitivity == "accent" { + return compare_collation_keys( + &normalized_left.to_lowercase(), + &normalized_right.to_lowercase(), + options.numeric, + ); + } + let case_order = case_first_order(&normalized_left, &normalized_right, &options.case_first); + if case_order != std::cmp::Ordering::Equal { + return case_order; + } + if options.sensitivity == "case" { + return compare_collation_keys( + &base_collation_key(&normalized_left, true), + &base_collation_key(&normalized_right, true), + options.numeric, + ); + } + compare_collation_keys(&normalized_left, &normalized_right, options.numeric) +} diff --git a/crates/perry-runtime/src/intl/display_names.rs b/crates/perry-runtime/src/intl/display_names.rs index dedd2c467c..e3aa6f1b01 100644 --- a/crates/perry-runtime/src/intl/display_names.rs +++ b/crates/perry-runtime/src/intl/display_names.rs @@ -79,7 +79,7 @@ fn get_options_object(options: f64) -> f64 { if jv.is_undefined() { return options; } - if object_ptr_from_value(options).is_some() { + if crate::proxy::js_proxy_is_proxy(options) != 0 || object_ptr_from_value(options).is_some() { return options; } throw_type_error("Intl.DisplayNames: options must be an object"); @@ -88,18 +88,26 @@ fn get_options_object(options: f64) -> f64 { /// Configure a freshly-allocated `Intl.DisplayNames` instance: validate the /// options bag (in spec order) and install the bound instance methods. pub(super) fn configure(obj: *mut ObjectHeader, options_arg: f64) { - let options = get_options_object(options_arg); + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let options_handle = scope.root_nanbox_f64(get_options_object(options_arg)); + let current_options = || options_handle.get_nanbox_f64(); // localeMatcher, then style, then type (required), then fallback, then // languageDisplay — the order the resolvedOptions / option-* tests rely on. let _matcher = dn_enum_option( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", ); - let style = dn_enum_option(options, "style", &["narrow", "short", "long"], "long"); - let type_ = match dn_get_option_string(options, "type") { + let style = dn_enum_option( + current_options(), + "style", + &["narrow", "short", "long"], + "long", + ); + let type_ = match dn_get_option_string(current_options(), "type") { Some(v) => { if ![ "language", @@ -119,26 +127,30 @@ pub(super) fn configure(obj: *mut ObjectHeader, options_arg: f64) { } None => throw_type_error("Intl.DisplayNames: options.type is required"), }; - let fallback = dn_enum_option(options, "fallback", &["code", "none"], "code"); + let fallback = dn_enum_option(current_options(), "fallback", &["code", "none"], "code"); // languageDisplay is read + validated unconditionally, but only applies to — // and is reported by resolvedOptions for — `type: "language"`. let language_display = dn_enum_option( - options, + current_options(), "languageDisplay", &["dialect", "standard"], "dialect", ); - set_internal_field(obj, KEY_STYLE, string_value(&style)); - set_internal_field(obj, KEY_TYPE, string_value(&type_)); - set_internal_field(obj, KEY_DN_FALLBACK, string_value(&fallback)); + set_internal_field_from_raw_handle(&obj_handle, KEY_STYLE, string_value(&style)); + set_internal_field_from_raw_handle(&obj_handle, KEY_TYPE, string_value(&type_)); + set_internal_field_from_raw_handle(&obj_handle, KEY_DN_FALLBACK, string_value(&fallback)); if type_ == "language" { - set_internal_field(obj, KEY_DN_LANG_DISPLAY, string_value(&language_display)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_DN_LANG_DISPLAY, + string_value(&language_display), + ); } - install_bound_instance_function(obj, "of", bound_of_thunk as *const u8, 1); - install_bound_instance_function( - obj, + install_bound_instance_function_from_handle(&obj_handle, "of", bound_of_thunk as *const u8, 1); + install_bound_instance_function_from_handle( + &obj_handle, "resolvedOptions", bound_resolved_options_thunk as *const u8, 0, diff --git a/crates/perry-runtime/src/intl/duration_format.rs b/crates/perry-runtime/src/intl/duration_format.rs index 386cc08b25..c55e3eefa8 100644 --- a/crates/perry-runtime/src/intl/duration_format.rs +++ b/crates/perry-runtime/src/intl/duration_format.rs @@ -122,8 +122,11 @@ fn get_duration_unit_options( digital_base: &str, prev_style: Option<&str>, ) -> (String, String) { + let scope = crate::gc::RuntimeHandleScope::new(); + let options_handle = scope.root_nanbox_f64(options); + let current_options = || options_handle.get_nanbox_f64(); // 1. style = GetOption(options, unit, string, allowed, undefined) - let mut style = match df_get_option_string(options, unit) { + let mut style = match df_get_option_string(current_options(), unit) { Some(v) => { if !allowed.contains(&v.as_str()) { throw_range_error(&format!( @@ -163,7 +166,7 @@ fn get_duration_unit_options( } // 6. display = GetOption(options, unitDisplay, string, «auto,always», displayDefault) let display = df_enum_option( - options, + current_options(), &display_key_field(unit), &["auto", "always"], display_default, @@ -212,16 +215,20 @@ fn report_style(style: &str) -> &str { /// Configure a freshly-allocated `Intl.DurationFormat` instance: read + validate /// the options bag (in spec order). pub(super) fn configure(obj: *mut ObjectHeader, options: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let options_handle = scope.root_nanbox_f64(options); + let current_options = || options_handle.get_nanbox_f64(); // GetOptionsObject: `undefined` → empty options; any other non-object // (notably `null` and primitives) is a TypeError. Object-like values — // including arrays, functions, and Proxies (all pointer-tagged) — are // accepted, so a property-bag Proxy still has its traps observed. Symbols // are also pointer-tagged, so `is_pointer()` alone would wrongly admit them; // exclude registered symbols explicitly. - let opts_jv = JSValue::from_bits(options.to_bits()); + let opts_jv = JSValue::from_bits(current_options().to_bits()); let is_symbol = opts_jv.is_pointer() && crate::symbol::is_registered_symbol( - (options.to_bits() & crate::value::POINTER_MASK) as usize, + (current_options().to_bits() & crate::value::POINTER_MASK) as usize, ); if !opts_jv.is_undefined() && (!opts_jv.is_pointer() || is_symbol) { throw_type_error("Intl.DurationFormat: options must be an object"); @@ -230,13 +237,13 @@ pub(super) fn configure(obj: *mut ObjectHeader, options: f64) { // Order (constructor-options-order): localeMatcher, numberingSystem, style, // then each unit + unitDisplay, then fractionalDigits. let _matcher = df_enum_option( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", ); - let opt_ns = match df_get_option_string(options, "numberingSystem") { + let opt_ns = match df_get_option_string(current_options(), "numberingSystem") { Some(ns) => { if !valid_numbering_system(&ns) { throw_range_error(&format!( @@ -250,42 +257,47 @@ pub(super) fn configure(obj: *mut ObjectHeader, options: f64) { // ResolveLocale for `nu`: reconcile the option with the requested locale's // `-u-nu-` keyword (stored in KEY_LOCALE at construction) and update both the // resolved locale and numbering system. - let locale = get_string_field(obj, KEY_LOCALE).unwrap_or_else(|| "en-US".to_string()); + let locale = get_string_field_from_raw_handle(&obj_handle, KEY_LOCALE) + .unwrap_or_else(|| "en-US".to_string()); let (resolved_locale, numbering) = super::resolve_numbering_system(&locale, opt_ns.as_deref()); - set_internal_field(obj, KEY_LOCALE, string_value(&resolved_locale)); - set_internal_field(obj, KEY_DF_NUMBERING, string_value(&numbering)); + set_internal_field_from_raw_handle(&obj_handle, KEY_LOCALE, string_value(&resolved_locale)); + set_internal_field_from_raw_handle(&obj_handle, KEY_DF_NUMBERING, string_value(&numbering)); let base_style = df_enum_option( - options, + current_options(), "style", &["long", "short", "narrow", "digital"], "short", ); - set_internal_field(obj, KEY_DF_STYLE, string_value(&base_style)); + set_internal_field_from_raw_handle(&obj_handle, KEY_DF_STYLE, string_value(&base_style)); let mut prev_style: Option = None; for (unit, allowed, digital_base) in UNITS.iter().copied() { let (style, display) = get_duration_unit_options( - options, + current_options(), unit, allowed, &base_style, digital_base, prev_style.as_deref(), ); - set_internal_field(obj, &style_key(unit), string_value(report_style(&style))); - set_internal_field(obj, &display_key(unit), string_value(&display)); + set_internal_field_from_raw_handle( + &obj_handle, + &style_key(unit), + string_value(report_style(&style)), + ); + set_internal_field_from_raw_handle(&obj_handle, &display_key(unit), string_value(&display)); prev_style = Some(style); } // fractionalDigits: integer in [0, 9], else RangeError. Read last. - if let Some(n) = get_option_number(options, "fractionalDigits") { + if let Some(n) = get_option_number(current_options(), "fractionalDigits") { if !n.is_finite() || n.fract() != 0.0 || !(0.0..=9.0).contains(&n) { throw_range_error( "Value out of range for Intl.DurationFormat options property fractionalDigits", ); } - set_internal_field(obj, KEY_DF_FRACTIONAL, n); + set_internal_field_from_raw_handle(&obj_handle, KEY_DF_FRACTIONAL, n); } // Unlike `Intl.NumberFormat.prototype.format` (a bound getter), the @@ -294,17 +306,24 @@ pub(super) fn configure(obj: *mut ObjectHeader, options: f64) { // We install them as own instance properties (Perry's method dispatch resolves // own properties) but back them with the implicit-`this` thunks, so a // detached call lands on an undefined receiver and `RequireInternalSlot` throws. - super::install_function(obj, "format", format_thunk as *const u8, 1, 1, false); - super::install_function( - obj, + super::install_function_from_handle( + &obj_handle, + "format", + format_thunk as *const u8, + 1, + 1, + false, + ); + super::install_function_from_handle( + &obj_handle, "formatToParts", to_parts_thunk as *const u8, 1, 1, false, ); - super::install_function( - obj, + super::install_function_from_handle( + &obj_handle, "resolvedOptions", resolved_options_thunk as *const u8, 0, diff --git a/crates/perry-runtime/src/intl/list_relative_plural.rs b/crates/perry-runtime/src/intl/list_relative_plural.rs index 3d1247d93a..152a0557cb 100644 --- a/crates/perry-runtime/src/intl/list_relative_plural.rs +++ b/crates/perry-runtime/src/intl/list_relative_plural.rs @@ -342,46 +342,161 @@ fn rtf_auto_word(value: f64, unit: &str, style: &str) -> Option<&'static str> { } } -/// Build en-US relative-time parts for `value` in `unit`. -/// -/// When `numeric == "auto"`, substitutes the CLDR relative word form -/// (`"yesterday"` / `"today"` / `"tomorrow"`, `"last/this/next "`, -/// `"now"`, …) for the discrete integer values CLDR names; otherwise (and for -/// every other value) renders the long numeric form (`"in 2 days"` / -/// `"1 day ago"`). `format` and `formatToParts` share this path so they stay -/// consistent. A word-form result is a single `"literal"` part (no unit field), -/// matching Node / ECMA-402 FormatRelativeTimeToParts. -/// -/// `style` is consulted only for the auto word forms (`week`/`month`/ -/// `quarter`/`year` short abbreviations); the numeric path still uses the long -/// unit names (a pre-existing limitation of the en-US fallback formatter). +fn intl_language(locale: &str) -> &str { + locale.split(['-', '_']).next().unwrap_or(locale) +} + +fn polish_plural(value: f64) -> &'static str { + let abs = value.abs(); + if abs.fract() != 0.0 { + return "other"; + } + let i = abs as u64; + if i == 1 { + "one" + } else if matches!(i % 10, 2..=4) && !matches!(i % 100, 12..=14) { + "few" + } else { + "many" + } +} + +fn polish_unit(unit: &str, style: &str, category: &str) -> &'static str { + if style != "long" { + return match (style, unit, category) { + ("narrow", "second", _) => "s", + (_, "second", _) => "sek.", + (_, "minute", _) => "min", + ("narrow", "hour", _) => "g.", + (_, "hour", _) => "godz.", + (_, "day", "one") => "dzień", + (_, "day", "other") => "dnia", + (_, "day", _) => "dni", + (_, "week", "one") => "tydz.", + (_, "week", "other") => "tyg.", + (_, "week", _) => "tyg.", + (_, "month", _) => "mies.", + (_, "quarter", _) => "kw.", + (_, "year", "one") => "rok", + (_, "year", "few") => "lata", + (_, "year", "other") => "roku", + (_, "year", _) => "lat", + _ => "", + }; + } + match (unit, category) { + ("second", "one") => "sekundę", + ("second", "few" | "other") => "sekundy", + ("second", _) => "sekund", + ("minute", "one") => "minutę", + ("minute", "few" | "other") => "minuty", + ("minute", _) => "minut", + ("hour", "one") => "godzinę", + ("hour", "few" | "other") => "godziny", + ("hour", _) => "godzin", + ("day", "one") => "dzień", + ("day", "other") => "dnia", + ("day", _) => "dni", + ("week", "one") => "tydzień", + ("week", "few") => "tygodnie", + ("week", "other") => "tygodnia", + ("week", _) => "tygodni", + ("month", "one") => "miesiąc", + ("month", "few") => "miesiące", + ("month", "other") => "miesiąca", + ("month", _) => "miesięcy", + ("quarter", "one") => "kwartał", + ("quarter", "few") => "kwartały", + ("quarter", "other") => "kwartału", + ("quarter", _) => "kwartałów", + ("year", "one") => "rok", + ("year", "few") => "lata", + ("year", "other") => "roku", + ("year", _) => "lat", + _ => "", + } +} + +fn english_unit(unit: &str, style: &str, singular: bool) -> String { + if style == "long" { + return if singular { + unit.to_string() + } else { + format!("{unit}s") + }; + } + match (unit, singular) { + ("second", _) => "sec.".to_string(), + ("minute", _) => "min.".to_string(), + ("hour", _) => "hr.".to_string(), + ("day", true) => "day".to_string(), + ("day", false) => "days".to_string(), + ("week", _) => "wk.".to_string(), + ("month", _) => "mo.".to_string(), + ("quarter", true) => "qtr.".to_string(), + ("quarter", false) => "qtrs.".to_string(), + ("year", _) => "yr.".to_string(), + _ => unit.to_string(), + } +} + +/// Build relative-time parts around the shared NumberFormat rendering core. +/// This keeps digit substitution, grouping, separators, and typed number parts +/// identical to `new Intl.NumberFormat(locale).formatToParts(value)`. pub(crate) fn rtf_parts( value: f64, unit: &str, numeric: &str, style: &str, + locale: &str, + numbering_system: &str, ) -> Vec<(&'static str, String)> { - if numeric == "auto" { + let language = intl_language(locale); + if language == "en" && numeric == "auto" { if let Some(word) = rtf_auto_word(value, unit, style) { return vec![("literal", word.to_string())]; } } let abs = value.abs(); - let num_str = format_number_parts(abs, "en-US", None, None); - let unit_display = if abs == 1.0 { - unit.to_string() + let mut resolved = nf_resolved_default(locale); + resolved.numbering_system = numbering_system.to_string(); + if language == "pl" { + resolved.use_grouping = "min2".to_string(); + } + let number_parts = number_parts_from_resolved(&resolved, abs); + let unit_display = if language == "pl" { + polish_unit(unit, style, polish_plural(abs)).to_string() } else { - format!("{unit}s") + english_unit(unit, style, abs == 1.0) }; let past = value.is_sign_negative(); let mut parts: Vec<(&'static str, String)> = Vec::new(); - if past { - split_numeric_parts(&num_str, "en-US", &mut parts); - parts.push(("literal", format!(" {unit_display} ago"))); + if language == "pl" { + if !past { + parts.push(("literal", "za ".to_string())); + } + parts.extend(number_parts); + parts.push(( + "literal", + if past { + format!(" {unit_display} temu") + } else { + format!(" {unit_display}") + }, + )); } else { - parts.push(("literal", "in ".to_string())); - split_numeric_parts(&num_str, "en-US", &mut parts); - parts.push(("literal", format!(" {unit_display}"))); + if !past { + parts.push(("literal", "in ".to_string())); + } + parts.extend(number_parts); + parts.push(( + "literal", + if past { + format!(" {unit_display} ago") + } else { + format!(" {unit_display}") + }, + )); } parts } @@ -417,6 +532,9 @@ pub(crate) fn rtf_instance_parts_and_unit( // being held as a raw pointer across a GC-capable call (#6960 / CodeRabbit). let numeric = get_string_field(obj, KEY_NUMERIC).unwrap_or_else(|| "always".to_string()); let style = get_string_field(obj, KEY_RTF_STYLE).unwrap_or_else(|| "long".to_string()); + let locale = get_string_field(obj, KEY_LOCALE).unwrap_or_else(|| "en-US".to_string()); + let numbering_system = + get_string_field(obj, KEY_RTF_NUMBERING).unwrap_or_else(|| "latn".to_string()); // ToNumber: a Symbol/BigInt value throws TypeError *before* the finite-ness // RangeError (format/value-symbol.js); an object's valueOf is invoked. let number = to_number_reject_bigint(value); @@ -434,7 +552,10 @@ pub(crate) fn rtf_instance_parts_and_unit( "Value {unit_str} out of range for Intl.RelativeTimeFormat.format() unit" )); }; - (rtf_parts(number, unit, &numeric, &style), unit) + ( + rtf_parts(number, unit, &numeric, &style, &locale, &numbering_system), + unit, + ) } pub(crate) fn rtf_instance_parts( @@ -527,7 +648,13 @@ pub(crate) fn rtf_resolved_options_object(obj: *const ObjectHeader) -> f64 { "numeric", string_value(&get_string_field(obj, KEY_NUMERIC).unwrap_or_else(|| "always".to_string())), ); - set_field(out, "numberingSystem", string_value("latn")); + set_field( + out, + "numberingSystem", + string_value( + &get_string_field(obj, KEY_RTF_NUMBERING).unwrap_or_else(|| "latn".to_string()), + ), + ); js_nanbox_pointer(out as i64) } @@ -543,6 +670,153 @@ pub(crate) extern "C" fn rtf_bound_resolved_options_thunk(closure: *const Closur // ---- Intl.PluralRules ------------------------------------------------------ +fn plural_digit_option(options: f64, key: &str, min: f64, max: f64) -> Option { + let value = get_option_value(options, key); + if JSValue::from_bits(value.to_bits()).is_undefined() { + return None; + } + let number = to_number_reject_bigint(value); + let integer = number.trunc(); + if integer.is_nan() || integer < min || integer > max { + throw_range_error(&format!( + "Value {number} out of range for Intl.PluralRules options property {key}" + )); + } + Some(integer) +} + +pub(super) fn configure_plural_rules( + obj: *mut ObjectHeader, + options_handle: &crate::gc::RuntimeHandle<'_>, +) -> *mut ObjectHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + // `? GetOptionsObject(options)`, then GetOption in the exact order asserted + // by constructor-option-read-order.js. Every read reloads the rooted Proxy: + // its getter appends to a JS array and can therefore move both arguments and + // the partially initialized result during GC. + let _ = get_options_object(options_handle.get_nanbox_f64()); + let _ = enum_option_strict( + options_handle.get_nanbox_f64(), + "localeMatcher", + &["lookup", "best fit"], + "best fit", + ); + let pr_type = enum_option_strict( + options_handle.get_nanbox_f64(), + "type", + &["cardinal", "ordinal"], + "cardinal", + ); + set_internal_field_from_raw_handle(&obj_handle, KEY_TYPE, string_value(&pr_type)); + let notation = enum_option_strict( + options_handle.get_nanbox_f64(), + "notation", + &["standard", "scientific", "engineering", "compact"], + "standard", + ); + let compact_display = enum_option_strict( + options_handle.get_nanbox_f64(), + "compactDisplay", + &["short", "long"], + "short", + ); + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_NOTATION, string_value(¬ation)); + if notation == "compact" { + set_internal_field_from_raw_handle( + &obj_handle, + KEY_PR_COMPACT_DISPLAY, + string_value(&compact_display), + ); + } + let min_int = plural_digit_option( + options_handle.get_nanbox_f64(), + "minimumIntegerDigits", + 1.0, + 21.0, + ) + .unwrap_or(1.0); + let min_frac_read = plural_digit_option( + options_handle.get_nanbox_f64(), + "minimumFractionDigits", + 0.0, + 100.0, + ); + let max_frac_read = plural_digit_option( + options_handle.get_nanbox_f64(), + "maximumFractionDigits", + 0.0, + 100.0, + ); + let min_sig = plural_digit_option( + options_handle.get_nanbox_f64(), + "minimumSignificantDigits", + 1.0, + 21.0, + ); + let max_sig = plural_digit_option( + options_handle.get_nanbox_f64(), + "maximumSignificantDigits", + 1.0, + 21.0, + ); + let _ = get_option_value(options_handle.get_nanbox_f64(), "roundingIncrement"); + let _ = get_option_value(options_handle.get_nanbox_f64(), "roundingMode"); + let _ = get_option_value(options_handle.get_nanbox_f64(), "roundingPriority"); + let _ = get_option_value(options_handle.get_nanbox_f64(), "trailingZeroDisplay"); + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_MIN_INT, min_int); + if min_sig.is_some() || max_sig.is_some() { + let min_sig = min_sig.unwrap_or(1.0); + let max_sig = max_sig.unwrap_or(21.0); + if max_sig < min_sig { + throw_range_error( + "maximumSignificantDigits is below minimumSignificantDigits for Intl.PluralRules", + ); + } + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_USE_SIG, bool_value(true)); + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_MIN_SIG, min_sig); + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_MAX_SIG, max_sig); + } else { + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_USE_SIG, bool_value(false)); + let min_frac = min_frac_read.unwrap_or(0.0); + let max_frac = max_frac_read.unwrap_or_else(|| min_frac.max(3.0)); + if max_frac < min_frac { + throw_range_error( + "maximumFractionDigits is below minimumFractionDigits for Intl.PluralRules", + ); + } + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_MIN_FRAC, min_frac); + set_internal_field_from_raw_handle(&obj_handle, KEY_PR_MAX_FRAC, max_frac); + } + obj_handle.with_mut_ptr(|obj| { + install_bound_instance_function( + obj, + "select", + plural_rules_bound_select_thunk as *const u8, + 1, + ) + }); + obj_handle.with_mut_ptr(|obj| { + install_function( + obj, + "selectRange", + plural_rules_select_range_thunk as *const u8, + 2, + 2, + false, + ) + }); + obj_handle.with_mut_ptr(|obj| { + install_bound_instance_function( + obj, + "resolvedOptions", + plural_rules_bound_resolved_options_thunk as *const u8, + 0, + ) + }); + obj_handle.across_mut::(|| ()).1 +} + /// en plural-category selection. Cardinal: `i == 1 && v == 0` → "one". Ordinal /// (UTS #35 en ordinal rules): 1st→"one", 2nd→"two", 3rd→"few", else "other". pub(crate) fn plural_select_en(n: f64, is_ordinal: bool) -> &'static str { @@ -570,18 +844,131 @@ pub(crate) fn plural_select_en(n: f64, is_ordinal: bool) -> &'static str { } } -pub(crate) fn plural_categories(is_ordinal: bool) -> &'static [&'static str] { +pub(crate) fn plural_categories(locale: &str, is_ordinal: bool) -> &'static [&'static str] { if is_ordinal { - &["one", "two", "few", "other"] - } else { - &["one", "other"] + return if intl_language(locale) == "en" { + &["one", "two", "few", "other"] + } else { + &["other"] + }; + } + match intl_language(locale) { + "ar" => &["zero", "one", "two", "few", "many", "other"], + "fa" | "en" => &["one", "other"], + "fr" => &["one", "many", "other"], + "gv" => &["one", "two", "few", "many", "other"], + "ko" => &["other"], + "sl" => &["one", "two", "few", "other"], + _ => &["one", "other"], + } +} + +fn plural_category(language: &str, is_ordinal: bool, notation: &str, n: f64) -> &'static str { + if is_ordinal { + return if language == "en" { + plural_select_en(n, true) + } else { + "other" + }; + } + if !n.is_finite() { + return "other"; + } + let abs = n.abs(); + if language == "fr" { + return if abs < 2.0 { + "one" + } else if (notation == "compact" && abs >= 1_000_000.0) + || (abs.fract() == 0.0 && abs != 0.0 && abs % 1_000_000.0 == 0.0) + { + "many" + } else { + "other" + }; + } + let integer = abs.fract() == 0.0; + let i = abs as u64; + match language { + "ar" if abs == 0.0 => "zero", + "ar" if abs == 1.0 => "one", + "ar" if abs == 2.0 => "two", + "ar" if integer && matches!(i % 100, 3..=10) => "few", + "ar" if integer && matches!(i % 100, 11..=99) => "many", + "ar" => "other", + "fa" if i == 0 || abs == 1.0 => "one", + "fa" => "other", + "gv" if !integer => "many", + "gv" if i % 10 == 1 => "one", + "gv" if i % 10 == 2 => "two", + "gv" if matches!(i % 100, 0 | 20 | 40 | 60 | 80) => "few", + "gv" => "other", + "ko" => "other", + "sl" if integer && i % 100 == 1 => "one", + "sl" if integer && i % 100 == 2 => "two", + "sl" if !integer || matches!(i % 100, 3..=4) => "few", + "sl" => "other", + _ => plural_select_en(n, false), } } pub(crate) fn plural_rules_select(obj: *const ObjectHeader, value: f64) -> f64 { - let n = JSValue::from_bits(value.to_bits()).to_number(); - let is_ordinal = get_string_field(obj, KEY_TYPE).as_deref() == Some("ordinal"); - string_value(plural_select_en(n, is_ordinal)) + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_const_ptr(obj); + let value = scope.root_nanbox_f64(value); + let is_ordinal = get_string_field_from_raw_handle(&obj, KEY_TYPE).as_deref() == Some("ordinal"); + let locale = + get_string_field_from_raw_handle(&obj, KEY_LOCALE).unwrap_or_else(|| "en-US".to_string()); + let notation = get_string_field_from_raw_handle(&obj, KEY_PR_NOTATION) + .unwrap_or_else(|| "standard".to_string()); + let n = to_number_reject_bigint(value.get_nanbox_f64()); + let language = intl_language(&locale); + string_value(plural_category(&language, is_ordinal, ¬ation, n)) +} + +#[cfg(test)] +mod plural_category_tests { + use super::*; + + #[test] + fn locale_selectors_only_return_advertised_categories() { + let samples = [ + 0.0, + 0.5, + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 10.0, + 11.0, + 20.0, + 21.0, + 100.0, + 1_000_000.0, + ]; + for locale in ["ar", "en", "fa", "fr", "gv", "ko", "sl"] { + for is_ordinal in [false, true] { + for value in samples { + let category = plural_category(locale, is_ordinal, "standard", value); + assert!( + plural_categories(locale, is_ordinal).contains(&category), + "{locale} ordinal={is_ordinal} value={value} returned {category}" + ); + } + } + } + } + + #[test] + fn locale_specific_cardinal_rules_cover_their_extra_categories() { + assert_eq!(plural_category("ar", false, "standard", 0.0), "zero"); + assert_eq!(plural_category("ar", false, "standard", 2.0), "two"); + assert_eq!(plural_category("ar", false, "standard", 7.0), "few"); + assert_eq!(plural_category("ar", false, "standard", 15.0), "many"); + assert_eq!(plural_category("gv", false, "standard", 1.5), "many"); + assert_eq!(plural_category("sl", false, "standard", 3.0), "few"); + assert_eq!(plural_category("ko", true, "standard", 1.0), "other"); + } } pub(crate) extern "C" fn plural_rules_select_thunk( @@ -609,15 +996,6 @@ pub(crate) extern "C" fn plural_rules_select_range_thunk( plural_select_range(start, end) } -pub(crate) extern "C" fn plural_rules_bound_select_range_thunk( - closure: *const ClosureHeader, - start: f64, - end: f64, -) -> f64 { - let _obj = captured_intl_object(closure, "selectRange", KIND_PLURAL_RULES); - plural_select_range(start, end) -} - pub(crate) fn plural_select_range(start: f64, end: f64) -> f64 { // PluralRules.prototype.selectRange(start, end): a `undefined` endpoint is a // TypeError (step 3), evaluated *before* the `? ToNumber` coercions — and @@ -693,7 +1071,8 @@ pub(crate) fn plural_rules_resolved_options_object(obj: *const ObjectHeader) -> ); } let mut categories = js_array_alloc(0); - for cat in plural_categories(is_ordinal) { + let locale = get_string_field(obj, KEY_LOCALE).unwrap_or_else(|| "en-US".to_string()); + for cat in plural_categories(&locale, is_ordinal) { categories = js_array_push_f64(categories, string_value(cat)); } set_field( diff --git a/crates/perry-runtime/src/intl/locale.rs b/crates/perry-runtime/src/intl/locale.rs index a49e8189ec..837d898a7a 100644 --- a/crates/perry-runtime/src/intl/locale.rs +++ b/crates/perry-runtime/src/intl/locale.rs @@ -317,27 +317,34 @@ fn base_name(p: &ParsedLocale) -> String { fn full_string(p: &ParsedLocale) -> String { let mut s = base_name(p); + let mut extensions = p.other_ext.clone(); if !p.attributes.is_empty() || !p.keywords.is_empty() { - s.push_str("-u"); + let mut unicode = String::new(); let mut attrs = p.attributes.clone(); attrs.sort(); for a in attrs { - s.push('-'); - s.push_str(&a); + if !unicode.is_empty() { + unicode.push('-'); + } + unicode.push_str(&a); } for (k, v) in &p.keywords { - s.push('-'); - s.push_str(k); + if !unicode.is_empty() { + unicode.push('-'); + } + unicode.push_str(k); if !v.is_empty() { - s.push('-'); - s.push_str(v); + unicode.push('-'); + unicode.push_str(v); } } + extensions.push(('u', unicode)); } - // Other extensions, sorted by singleton with private-use (`x`) last. - let mut others = p.other_ext.clone(); - others.sort_by_key(|(c, _)| if *c == 'x' { '{' } else { *c }); - for (c, content) in others { + // All extensions are sorted by singleton with private-use (`x`) last. The + // Unicode extension is not privileged: `en-a-bar-u-baz-x-private` keeps + // `a` before `u` (Locale/constructor-tag.js). + extensions.sort_by_key(|(c, _)| if *c == 'x' { '{' } else { *c }); + for (c, content) in extensions { s.push('-'); s.push(c); s.push('-'); @@ -669,7 +676,11 @@ pub(super) extern "C" fn locale_constructor_thunk(closure: *const ClosureHeader, throw_type_error("Intl.Locale: tag must be a String or an Intl.Locale instance"); }; - let Some(mut parsed) = parse_language_tag(&tag) else { + #[cfg(feature = "intl-locale")] + let canonical_tag = super::canonicalize_language_tag(&tag); + #[cfg(not(feature = "intl-locale"))] + let canonical_tag = Some(tag.clone()); + let Some(mut parsed) = canonical_tag.as_deref().and_then(parse_language_tag) else { throw_range_error(&format!("Incorrect locale information provided: {tag}")); }; canonicalize_aliases(&mut parsed); @@ -682,6 +693,18 @@ pub(super) extern "C" fn locale_constructor_thunk(closure: *const ClosureHeader, } } apply_options(&mut parsed, options); + // ApplyOptionsToTag canonicalizes both before and after the overrides. The + // second pass is observable for numeric region aliases (`554` -> `NZ`) and + // for replacements whose result depends on an overridden base subtag. + #[cfg(feature = "intl-locale")] + { + let adjusted = full_string(&parsed); + let canonical = super::canonicalize_language_tag(&adjusted) + .unwrap_or_else(|| throw_range_error("Incorrect locale information provided")); + parsed = parse_language_tag(&canonical) + .unwrap_or_else(|| throw_range_error("Incorrect locale information provided")); + canonicalize_aliases(&mut parsed); + } let proto = super::constructor_target_prototype(closure); make_locale_instance(proto.to_bits(), &parsed) diff --git a/crates/perry-runtime/src/intl/locale/likely_subtags.rs b/crates/perry-runtime/src/intl/locale/likely_subtags.rs index 16a4624734..75ceaaa36c 100644 --- a/crates/perry-runtime/src/intl/locale/likely_subtags.rs +++ b/crates/perry-runtime/src/intl/locale/likely_subtags.rs @@ -9,7 +9,24 @@ use super::ParsedLocale; +#[cfg(feature = "intl-locale")] +fn transform_with_icu(p: &mut ParsedLocale, maximize: bool) { + let Ok(mut locale) = super::full_string(p).parse::() else { + return; + }; + let expander = icu_locale::LocaleExpander::new_extended(); + if maximize { + let _ = expander.maximize(&mut locale.id); + } else { + let _ = expander.minimize(&mut locale.id); + } + if let Some(parsed) = super::parse_language_tag(&locale.to_string()) { + *p = parsed; + } +} + /// `language -> (script, region)` — the maximal expansion of a bare language. +#[cfg(not(feature = "intl-locale"))] const LANG: &[(&str, &str, &str)] = &[ ("en", "Latn", "US"), ("es", "Latn", "ES"), @@ -97,6 +114,7 @@ const LANG: &[(&str, &str, &str)] = &[ /// `(language, region) -> script` overrides where the region disambiguates the /// script (e.g. `zh-TW` is `Hant`, not the bare-`zh` default `Hans`). +#[cfg(not(feature = "intl-locale"))] const LANG_REGION: &[(&str, &str, &str)] = &[ ("zh", "TW", "Hant"), ("zh", "HK", "Hant"), @@ -109,6 +127,7 @@ const LANG_REGION: &[(&str, &str, &str)] = &[ /// `script -> language` — the most likely language for a script, used to fill a /// `und`-language tag during maximization. +#[cfg(not(feature = "intl-locale"))] const SCRIPT_LANG: &[(&str, &str)] = &[ ("Latn", "en"), ("Cyrl", "ru"), @@ -128,12 +147,14 @@ const SCRIPT_LANG: &[(&str, &str)] = &[ ("Beng", "bn"), ]; +#[cfg(not(feature = "intl-locale"))] fn lang_defaults(lang: &str) -> Option<(&'static str, &'static str)> { LANG.iter() .find(|(l, _, _)| *l == lang) .map(|(_, s, r)| (*s, *r)) } +#[cfg(not(feature = "intl-locale"))] fn script_for_region(lang: &str, region: &str) -> Option<&'static str> { LANG_REGION .iter() @@ -141,6 +162,7 @@ fn script_for_region(lang: &str, region: &str) -> Option<&'static str> { .map(|(_, _, s)| *s) } +#[cfg(not(feature = "intl-locale"))] fn lang_for_script(script: &str) -> Option<&'static str> { SCRIPT_LANG .iter() @@ -150,6 +172,7 @@ fn lang_for_script(script: &str) -> Option<&'static str> { /// Fully expand `(language, script, region)`, filling missing script/region from /// the table. Returns the (possibly unchanged) maximal triple. +#[cfg(not(feature = "intl-locale"))] fn maximize_triple( language: &str, script: Option, @@ -189,41 +212,58 @@ fn maximize_triple( /// `Intl.Locale.prototype.maximize`: add the most likely script and region. pub(super) fn maximize(p: &mut ParsedLocale) { - let (lang, script, region) = maximize_triple(&p.language, p.script.clone(), p.region.clone()); - p.language = lang; - p.script = script; - p.region = region; + #[cfg(feature = "intl-locale")] + { + transform_with_icu(p, true); + return; + } + #[cfg(not(feature = "intl-locale"))] + { + let (lang, script, region) = + maximize_triple(&p.language, p.script.clone(), p.region.clone()); + p.language = lang; + p.script = script; + p.region = region; + } } /// `Intl.Locale.prototype.minimize`: remove script/region that the /// likely-subtags expansion would re-add. Chooses the shortest base subtags /// whose maximization round-trips to the same maximal triple. pub(super) fn minimize(p: &mut ParsedLocale) { - let max = maximize_triple(&p.language, p.script.clone(), p.region.clone()); - // Minimization operates on the fully-resolved tag, so the result language is - // always the maximal language (e.g. `und-Latn` minimizes to `en`). - let lang = max.0.clone(); - p.language = lang.clone(); - - // 1. language alone. - if maximize_triple(&lang, None, None) == max { - p.script = None; - p.region = None; - return; - } - // 2. language + region. - if max.2.is_some() && maximize_triple(&lang, None, max.2.clone()) == max { - p.script = None; - p.region = max.2.clone(); + #[cfg(feature = "intl-locale")] + { + transform_with_icu(p, false); return; } - // 3. language + script. - if max.1.is_some() && maximize_triple(&lang, max.1.clone(), None) == max { - p.script = max.1.clone(); - p.region = None; - return; + #[cfg(not(feature = "intl-locale"))] + { + let max = maximize_triple(&p.language, p.script.clone(), p.region.clone()); + // Minimization operates on the fully-resolved tag, so the result language is + // always the maximal language (e.g. `und-Latn` minimizes to `en`). + let lang = max.0.clone(); + p.language = lang.clone(); + + // 1. language alone. + if maximize_triple(&lang, None, None) == max { + p.script = None; + p.region = None; + return; + } + // 2. language + region. + if max.2.is_some() && maximize_triple(&lang, None, max.2.clone()) == max { + p.script = None; + p.region = max.2.clone(); + return; + } + // 3. language + script. + if max.1.is_some() && maximize_triple(&lang, max.1.clone(), None) == max { + p.script = max.1.clone(); + p.region = None; + return; + } + // 4. keep the full maximal triple. + p.script = max.1; + p.region = max.2; } - // 4. keep the full maximal triple. - p.script = max.1; - p.region = max.2; } diff --git a/crates/perry-runtime/src/intl/locales.rs b/crates/perry-runtime/src/intl/locales.rs index e37eacee34..22dbe4cb58 100644 --- a/crates/perry-runtime/src/intl/locales.rs +++ b/crates/perry-runtime/src/intl/locales.rs @@ -3,45 +3,10 @@ //! under the per-file LOC ceiling. Canonicalization itself lives in //! [`super::canonicalize_language_tag`]. -use super::{ - array_ptr_from_value, canonicalize_language_tag, get_field, get_number_field, - locale_instance_tag, object_ptr_from_value, string_from_string_value, string_value, - throw_invalid_language_tag, throw_range_error, throw_type_error, value_to_string, -}; -use crate::array::{js_array_alloc, js_array_get_f64, js_array_length, js_array_push_f64}; +use super::{locales_from_value, string_value, throw_range_error, value_to_string}; +use crate::array::{js_array_alloc, js_array_push_f64}; use crate::closure::ClosureHeader; -use crate::value::{js_nanbox_pointer, JSValue}; - -/// The ECMA-402 element-type guard inside CanonicalizeLocaleList: each element -/// must be a String or an Object, else `TypeError`. A Locale/other object is -/// coerced via `ToString` (an `Intl.Locale` stringifies to its canonical id). -fn locale_list_element_tag(value: f64) -> String { - let js = JSValue::from_bits(value.to_bits()); - if js.is_any_string() { - return string_from_string_value(value).unwrap_or_default(); - } - // An `Intl.Locale` (or `class X extends Intl.Locale` subclass) element: - // CanonicalizeLocaleList reads its `[[Locale]]` slot directly, WITHOUT - // calling the (user-overridable) `toString` — checked before the generic - // ToString path below (test262 canonicalize-locale-list-take-locale.js). - if let Some(tag) = locale_instance_tag(value) { - return tag; - } - // Object (but not a Symbol, which is pointer-shaped yet a primitive). - if js.is_pointer() && unsafe { crate::symbol::js_is_symbol(value) } == 0 { - return value_to_string(value); - } - throw_type_error("locale must be a String or Object"); -} - -fn push_canonical_locale(seen: &mut Vec, tag: &str) { - let Some(canonical) = canonicalize_language_tag(tag) else { - throw_invalid_language_tag(tag); - }; - if !seen.iter().any(|existing| existing == &canonical) { - seen.push(canonical); - } -} +use crate::value::js_nanbox_pointer; pub(super) fn canonical_locales_array(list: &[String]) -> f64 { let mut arr = js_array_alloc(list.len() as u32); @@ -54,54 +19,10 @@ pub(super) fn canonical_locales_array(list: &[String]) -> f64 { /// `Intl.getCanonicalLocales(locales)` — CanonicalizeLocaleList then /// CreateArrayFromList. `undefined` → `[]`; a String → a single-element list; /// `null` → `TypeError`; an Array (or array-like Object) → its elements -/// canonicalized and de-duplicated, in order; any other primitive → `[]` -/// (`ToObject` yields a wrapper with no integer-indexed entries). +/// canonicalized and de-duplicated, in order. Other primitives are ToObject- +/// wrapped, so inherited array-like properties remain observable. pub(super) fn get_canonical_locales(locales: f64) -> f64 { - let js = JSValue::from_bits(locales.to_bits()); - let mut seen: Vec = Vec::new(); - - if js.is_undefined() { - return canonical_locales_array(&seen); - } - if js.is_null() { - throw_type_error("Cannot convert undefined or null to object"); - } - if js.is_any_string() { - let tag = string_from_string_value(locales).unwrap_or_default(); - push_canonical_locale(&mut seen, &tag); - return canonical_locales_array(&seen); - } - // CanonicalizeLocaleList step 2: a value with an `[[InitializedLocale]]` - // slot (an `Intl.Locale` or a subclass instance) is the single-element list - // « locale », read from its `[[Locale]]` slot — never iterated as an - // array-like nor stringified via `toString`. - if let Some(tag) = locale_instance_tag(locales) { - push_canonical_locale(&mut seen, &tag); - return canonical_locales_array(&seen); - } - if let Some(arr) = array_ptr_from_value(locales) { - let len = js_array_length(arr); - for i in 0..len { - let tag = locale_list_element_tag(js_array_get_f64(arr, i)); - push_canonical_locale(&mut seen, &tag); - } - return canonical_locales_array(&seen); - } - if let Some(obj) = object_ptr_from_value(locales) { - // Generic array-like: iterate `O[0..length]`. - let len = get_number_field(obj, "length") - .filter(|n| n.is_finite() && *n > 0.0) - .map(|n| n as u32) - .unwrap_or(0); - for i in 0..len { - let tag = locale_list_element_tag(get_field(obj, &i.to_string())); - push_canonical_locale(&mut seen, &tag); - } - return canonical_locales_array(&seen); - } - // Other primitives (number/boolean/symbol/bigint): ToObject succeeds but the - // wrapper has length 0 — an empty list, no throw. - canonical_locales_array(&seen) + canonical_locales_array(&locales_from_value(locales)) } pub(super) extern "C" fn get_canonical_locales_thunk( diff --git a/crates/perry-runtime/src/intl/method_install.rs b/crates/perry-runtime/src/intl/method_install.rs new file mode 100644 index 0000000000..7a288e17b5 --- /dev/null +++ b/crates/perry-runtime/src/intl/method_install.rs @@ -0,0 +1,131 @@ +use super::*; + +pub(super) fn install_bound_instance_function( + obj: *mut ObjectHeader, + name: &str, + func_ptr: *const u8, + arity: u32, +) -> *mut ClosureHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let closure = crate::closure::js_closure_alloc(func_ptr, 1); + if closure.is_null() { + return closure; + } + let closure = scope.root_raw_mut_ptr(closure); + crate::closure::js_register_closure_arity(func_ptr, arity); + closure.with_mut_ptr(|closure| { + obj.with_mut_ptr(|obj: *mut ObjectHeader| { + crate::closure::js_closure_set_capture_f64(closure, 0, js_nanbox_pointer(obj as i64)) + }) + }); + closure.with_mut_ptr(|closure| crate::object::set_bound_native_closure_name(closure, name)); + closure.with_mut_ptr::(|closure| { + crate::object::set_builtin_closure_length(closure as usize, arity) + }); + // A bound Intl instance method (`nf.format`, `nf.resolvedOptions`, …) is a + // built-in non-constructor function: it has NO `[[Construct]]` and therefore + // no own `prototype` property (ECMA-262 §17 — built-in functions that aren't + // constructors don't get the auto-created `.prototype`). Flag it so + // `function_would_have_own_prototype` / the `new` path treat it like any + // other builtin (`Math.max`), matching `format-function-builtin.js`. + closure.with_mut_ptr::(|closure| { + crate::object::set_builtin_closure_non_constructable(closure as usize) + }); + closure.with_mut_ptr(|closure: *mut ClosureHeader| { + crate::object::set_builtin_property_attrs( + closure as usize, + "name".to_string(), + PropertyAttrs::new(false, false, true), + ) + }); + closure.with_mut_ptr(|closure: *mut ClosureHeader| { + crate::object::set_builtin_property_attrs( + closure as usize, + "length".to_string(), + PropertyAttrs::new(false, false, true), + ) + }); + let closure_value = + closure.with_mut_ptr(|closure: *mut ClosureHeader| js_nanbox_pointer(closure as i64)); + obj.with_mut_ptr(|obj| set_field(obj, name, closure_value)); + obj.with_mut_ptr(|obj| set_builtin_attrs(obj, name, PropertyAttrs::new(true, false, true))); + closure.with_mut_ptr(|closure| closure) +} + +pub(super) fn install_bound_instance_function_from_handle( + obj: &crate::gc::RuntimeHandle<'_>, + name: &str, + func_ptr: *const u8, + arity: u32, +) -> *mut ClosureHeader { + obj.with_mut_ptr(|obj| install_bound_instance_function(obj, name, func_ptr, arity)) +} + +pub(super) fn install_function( + owner: *mut ObjectHeader, + name: &str, + func_ptr: *const u8, + call_arity: u32, + length: u32, + has_rest: bool, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_raw_mut_ptr(owner); + let closure = crate::closure::js_closure_alloc(func_ptr, 0); + if closure.is_null() { + return undefined(); + } + let closure = scope.root_raw_mut_ptr(closure); + if has_rest { + crate::closure::js_register_closure_rest(func_ptr, call_arity); + } else { + crate::closure::js_register_closure_arity(func_ptr, call_arity); + } + closure.with_mut_ptr(|closure| crate::object::set_bound_native_closure_name(closure, name)); + closure.with_mut_ptr::(|closure| { + crate::object::set_builtin_closure_length(closure as usize, length) + }); + // Intl prototype methods (`formatToParts`, `resolvedOptions`, …), the static + // `supportedLocalesOf`, and the this-based instance methods + // (`formatRange`/`formatRangeToParts`) installed through here are all + // built-in non-constructor functions: no `[[Construct]]`, hence no own + // `prototype` property (`builtin.js` asserts `hasOwnProperty("prototype")` + // is false and `isConstructor` is false). Flag them like any other builtin. + closure.with_mut_ptr::(|closure| { + crate::object::set_builtin_closure_non_constructable(closure as usize) + }); + closure.with_mut_ptr(|closure: *mut ClosureHeader| { + crate::object::set_builtin_property_attrs( + closure as usize, + "name".to_string(), + PropertyAttrs::new(false, false, true), + ) + }); + closure.with_mut_ptr(|closure: *mut ClosureHeader| { + crate::object::set_builtin_property_attrs( + closure as usize, + "length".to_string(), + PropertyAttrs::new(false, false, true), + ) + }); + let value = + closure.with_mut_ptr(|closure: *mut ClosureHeader| js_nanbox_pointer(closure as i64)); + owner.with_mut_ptr(|owner| set_field(owner, name, value)); + owner.with_mut_ptr(|owner| { + set_builtin_attrs(owner, name, PropertyAttrs::new(true, false, true)) + }); + value +} + +pub(super) fn install_function_from_handle( + owner: &crate::gc::RuntimeHandle<'_>, + name: &str, + func_ptr: *const u8, + call_arity: u32, + length: u32, + has_rest: bool, +) -> f64 { + owner + .with_mut_ptr(|owner| install_function(owner, name, func_ptr, call_arity, length, has_rest)) +} diff --git a/crates/perry-runtime/src/intl/number_format_options.rs b/crates/perry-runtime/src/intl/number_format_options.rs index 26e0fc8566..ed4cdda077 100644 --- a/crates/perry-runtime/src/intl/number_format_options.rs +++ b/crates/perry-runtime/src/intl/number_format_options.rs @@ -6,9 +6,13 @@ use crate::value::JSValue; /// Read, validate, and store the NumberFormat option slots (ECMA-402 /// CreateNumberFormat / SetNumberFormatUnitOptions / SetNumberFormatDigitOptions). pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, options: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(obj); + let options_handle = scope.root_nanbox_f64(options); + let current_options = || options_handle.get_nanbox_f64(); // CoerceOptionsToObject: `null` throws; `undefined` behaves as an empty // null-prototype object (our readers already treat non-objects as empty). - if JSValue::from_bits(options.to_bits()).is_null() { + if JSValue::from_bits(current_options().to_bits()).is_null() { throw_type_error("Cannot convert undefined or null to object"); } @@ -18,7 +22,7 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti // constructor-option-read-order.js asserts (localeMatcher before // numberingSystem) and propagates a throwing localeMatcher getter. let _ = get_string_option_enum( - options, + current_options(), "localeMatcher", &["lookup", "best fit"], "best fit", @@ -28,7 +32,7 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti // then run ResolveLocale for the `nu` key — reconciling the option with the // requested locale's `-u-nu-` keyword and updating the resolved locale so // `resolvedOptions().locale` reflects only the supported value actually used. - let opt_ns = match get_option_string(options, "numberingSystem") { + let opt_ns = match get_option_string(current_options(), "numberingSystem") { Some(value) => { let lower = value.to_ascii_lowercase(); if !is_well_formed_numbering_system(&lower) { @@ -41,24 +45,28 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti None => None, }; let (resolved_locale, numbering) = resolve_numbering_system(locale, opt_ns.as_deref()); - set_internal_field(obj, KEY_LOCALE, string_value(&resolved_locale)); - set_internal_field(obj, KEY_NF_NUMBERING, string_value(&numbering)); + set_internal_field_from_raw_handle(&obj_handle, KEY_LOCALE, string_value(&resolved_locale)); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_NUMBERING, string_value(&numbering)); // SetNumberFormatUnitOptions. let style = get_string_option_enum( - options, + current_options(), "style", &["decimal", "percent", "currency", "unit"], "decimal", ); - set_internal_field(obj, KEY_STYLE, string_value(&style)); + set_internal_field_from_raw_handle(&obj_handle, KEY_STYLE, string_value(&style)); - let currency = get_option_string(options, "currency"); + let currency = get_option_string(current_options(), "currency"); if let Some(code) = ¤cy { if !is_well_formed_currency_code(code) { throw_range_error(&format!("Invalid currency code : {code}")); } - set_internal_field(obj, KEY_CURRENCY, string_value(&code.to_ascii_uppercase())); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_CURRENCY, + string_value(&code.to_ascii_uppercase()), + ); } // Throw TypeError for missing currency BEFORE reading currencyDisplay / // currencySign — so proxy-get traps on those keys are never triggered when @@ -67,40 +75,48 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti throw_type_error("Currency code is required with currency style."); } let currency_display = get_string_option_enum( - options, + current_options(), "currencyDisplay", &["code", "symbol", "narrowSymbol", "name"], "symbol", ); let currency_sign = get_string_option_enum( - options, + current_options(), "currencySign", &["standard", "accounting"], "standard", ); - set_internal_field( - obj, + set_internal_field_from_raw_handle( + &obj_handle, KEY_NF_CURRENCY_DISPLAY, string_value(¤cy_display), ); - set_internal_field(obj, KEY_NF_CURRENCY_SIGN, string_value(¤cy_sign)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NF_CURRENCY_SIGN, + string_value(¤cy_sign), + ); - let unit = get_option_string(options, "unit"); + let unit = get_option_string(current_options(), "unit"); if let Some(u) = &unit { if !is_well_formed_unit_identifier(u) { throw_range_error(&format!( "Value {u} out of range for Intl.NumberFormat options property unit" )); } - set_internal_field(obj, KEY_NF_UNIT, string_value(u)); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_UNIT, string_value(u)); } let unit_display = get_string_option_enum( - options, + current_options(), "unitDisplay", &["short", "narrow", "long"], "short", ); - set_internal_field(obj, KEY_NF_UNIT_DISPLAY, string_value(&unit_display)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NF_UNIT_DISPLAY, + string_value(&unit_display), + ); if style == "unit" && unit.is_none() { throw_type_error("unit is required with unit style."); @@ -108,33 +124,37 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti // notation (read before the digit options per the spec order). let notation = get_string_option_enum( - options, + current_options(), "notation", &["standard", "scientific", "engineering", "compact"], "standard", ); - set_internal_field(obj, KEY_NF_NOTATION, string_value(¬ation)); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_NOTATION, string_value(¬ation)); // SetNumberFormatDigitOptions — the GetOption reads run in the exact // ECMA-402 order asserted by constructor-option-read-order.js: // minimumIntegerDigits, minimumFractionDigits, maximumFractionDigits, // minimumSignificantDigits, maximumSignificantDigits, roundingIncrement, // roundingMode, roundingPriority, trailingZeroDisplay. - let min_int = - get_int_option_in_range(options, "minimumIntegerDigits", 1.0, 21.0).unwrap_or(1.0); - let min_frac_opt = get_int_option_in_range(options, "minimumFractionDigits", 0.0, 100.0); - let max_frac_opt = get_int_option_in_range(options, "maximumFractionDigits", 0.0, 100.0); - let min_sig_opt = get_int_option_in_range(options, "minimumSignificantDigits", 1.0, 21.0); - let max_sig_opt = get_int_option_in_range(options, "maximumSignificantDigits", 1.0, 21.0); + let min_int = get_int_option_in_range(current_options(), "minimumIntegerDigits", 1.0, 21.0) + .unwrap_or(1.0); + let min_frac_opt = + get_int_option_in_range(current_options(), "minimumFractionDigits", 0.0, 100.0); + let max_frac_opt = + get_int_option_in_range(current_options(), "maximumFractionDigits", 0.0, 100.0); + let min_sig_opt = + get_int_option_in_range(current_options(), "minimumSignificantDigits", 1.0, 21.0); + let max_sig_opt = + get_int_option_in_range(current_options(), "maximumSignificantDigits", 1.0, 21.0); // roundingIncrement is read before roundingMode/roundingPriority and is // ToNumber-coerced (so `{ valueOf }` objects work) then checked against the // sanctioned increment set — a [1, 5000] range alone would wrongly admit // values like 3 or 5000.1. - let rounding_increment = read_rounding_increment(options); + let rounding_increment = read_rounding_increment(current_options()); let rounding_mode = enum_option_strict( - options, + current_options(), "roundingMode", &[ "ceil", @@ -150,19 +170,19 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti "halfExpand", ); let mut rounding_priority = get_string_option_enum( - options, + current_options(), "roundingPriority", &["auto", "morePrecision", "lessPrecision"], "auto", ); let trailing_zero = get_string_option_enum( - options, + current_options(), "trailingZeroDisplay", &["auto", "stripIfInteger"], "auto", ); - set_internal_field(obj, KEY_NF_MIN_INT, min_int); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_MIN_INT, min_int); // The currency-specific digit defaults only apply to "standard" notation // (ECMA-402 SetNumberFormatDigitOptions step 19-20) — compact/engineering/ @@ -256,10 +276,10 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti } } - set_internal_field(obj, KEY_NF_MIN_SIG, min_sig as f64); - set_internal_field(obj, KEY_NF_MAX_SIG, max_sig as f64); - set_internal_field(obj, KEY_NF_MIN_FRAC, min_frac as f64); - set_internal_field(obj, KEY_MAX_FRACTION_DIGITS, max_frac as f64); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_MIN_SIG, min_sig as f64); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_MAX_SIG, max_sig as f64); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_MIN_FRAC, min_frac as f64); + set_internal_field_from_raw_handle(&obj_handle, KEY_MAX_FRACTION_DIGITS, max_frac as f64); // Digit display mode: "fraction" | "significant" | "both" (compact default). let digit_mode = if has_sd && !has_fd { @@ -280,42 +300,66 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti }; // Compact's significant defaults are 1–2 when not explicitly given. if digit_mode == "both" { - set_internal_field(obj, KEY_NF_MIN_SIG, 1.0); - set_internal_field(obj, KEY_NF_MAX_SIG, 2.0); - set_internal_field(obj, KEY_NF_MIN_FRAC, 0.0); - set_internal_field(obj, KEY_MAX_FRACTION_DIGITS, 0.0); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_MIN_SIG, 1.0); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_MAX_SIG, 2.0); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_MIN_FRAC, 0.0); + set_internal_field_from_raw_handle(&obj_handle, KEY_MAX_FRACTION_DIGITS, 0.0); } - set_internal_field(obj, KEY_NF_USE_SIG, string_value(digit_mode)); + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_USE_SIG, string_value(digit_mode)); - set_internal_field(obj, KEY_NF_ROUNDING_INCREMENT, rounding_increment); - set_internal_field(obj, KEY_NF_ROUNDING_MODE, string_value(&rounding_mode)); - set_internal_field( - obj, + set_internal_field_from_raw_handle(&obj_handle, KEY_NF_ROUNDING_INCREMENT, rounding_increment); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NF_ROUNDING_MODE, + string_value(&rounding_mode), + ); + set_internal_field_from_raw_handle( + &obj_handle, KEY_NF_ROUNDING_PRIORITY, string_value(&rounding_priority), ); - set_internal_field(obj, KEY_NF_TRAILING_ZERO, string_value(&trailing_zero)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NF_TRAILING_ZERO, + string_value(&trailing_zero), + ); // compactDisplay, useGrouping, signDisplay. - let compact_display = - get_string_option_enum(options, "compactDisplay", &["short", "long"], "short"); - set_internal_field(obj, KEY_NF_COMPACT_DISPLAY, string_value(&compact_display)); + let compact_display = get_string_option_enum( + current_options(), + "compactDisplay", + &["short", "long"], + "short", + ); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NF_COMPACT_DISPLAY, + string_value(&compact_display), + ); let default_grouping = if notation == "compact" { "min2" } else { "auto" }; - let use_grouping = get_use_grouping_option(options, default_grouping); - set_internal_field(obj, KEY_NF_USE_GROUPING, string_value(&use_grouping)); + let use_grouping = get_use_grouping_option(current_options(), default_grouping); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NF_USE_GROUPING, + string_value(&use_grouping), + ); let sign_display = get_string_option_enum( - options, + current_options(), "signDisplay", &["auto", "never", "always", "exceptZero", "negative"], "auto", ); - set_internal_field(obj, KEY_NF_SIGN_DISPLAY, string_value(&sign_display)); + set_internal_field_from_raw_handle( + &obj_handle, + KEY_NF_SIGN_DISPLAY, + string_value(&sign_display), + ); } /// GetNumberOption(options, "roundingIncrement", 1, 5000, 1) followed by the diff --git a/crates/perry-runtime/src/intl/rooted_fields.rs b/crates/perry-runtime/src/intl/rooted_fields.rs new file mode 100644 index 0000000000..3c261971b7 --- /dev/null +++ b/crates/perry-runtime/src/intl/rooted_fields.rs @@ -0,0 +1,94 @@ +use super::*; + +pub(super) fn get_field(value: *const ObjectHeader, key: &str) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let value = scope.root_raw_const_ptr(value); + let key = scope.root_string_ptr(js_string_from_bytes(key.as_ptr(), key.len() as u32)); + value.with_const_ptr(|value| { + key.with_const_ptr(|key| js_object_get_field_by_name_f64(value, key)) + }) +} + +pub(super) fn get_field_from_raw_handle(value: &crate::gc::RuntimeHandle<'_>, key: &str) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let key = scope.root_string_ptr(js_string_from_bytes(key.as_ptr(), key.len() as u32)); + value.with_const_ptr::(|value| { + key.with_const_ptr(|key| js_object_get_field_by_name_f64(value, key)) + }) +} + +pub(super) fn get_string_field_from_raw_handle( + value: &crate::gc::RuntimeHandle<'_>, + key: &str, +) -> Option { + string_from_string_value(get_field_from_raw_handle(value, key)) +} + +pub(super) fn set_internal_field_from_raw_handle( + value: &crate::gc::RuntimeHandle<'_>, + key: &str, + field: f64, +) { + value.with_mut_ptr(|value| set_internal_field(value, key, field)); +} + +pub(super) fn get_field_from_value_handle(value: &crate::gc::RuntimeHandle<'_>, key: &str) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let key = scope.root_string_ptr(js_string_from_bytes(key.as_ptr(), key.len() as u32)); + let current = value.get_nanbox_f64(); + let Some(object) = object_ptr_from_value(current) else { + return undefined(); + }; + key.with_const_ptr(|key| js_object_get_field_by_name_f64(object, key)) +} + +pub(super) fn set_field(obj: *mut ObjectHeader, key: &str, value: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let value = scope.root_nanbox_f64(value); + let key = scope.root_string_ptr(js_string_from_bytes(key.as_ptr(), key.len() as u32)); + obj.with_mut_ptr(|obj| { + key.with_const_ptr(|key| js_object_set_field_by_name(obj, key, value.get_nanbox_f64())) + }); +} + +pub(super) fn set_builtin_attrs(obj: *mut ObjectHeader, key: &str, attrs: PropertyAttrs) { + set_builtin_property_attrs(obj as usize, key.to_string(), attrs); +} + +pub(super) fn set_internal_field(obj: *mut ObjectHeader, key: &str, value: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + obj.with_mut_ptr(|obj| set_field(obj, key, value)); + obj.with_mut_ptr(|obj| set_builtin_attrs(obj, key, PropertyAttrs::new(true, false, true))); +} + +pub(super) fn get_string_field(obj: *const ObjectHeader, key: &str) -> Option { + string_from_string_value(get_field(obj, key)) +} + +pub(super) fn get_number_field(obj: *const ObjectHeader, key: &str) -> Option { + let value = get_field(obj, key); + let js = JSValue::from_bits(value.to_bits()); + if js.is_undefined() || js.is_null() { + None + } else { + Some(js.to_number()) + } +} + +pub(super) fn get_option_value(options: f64, key: &str) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let options = scope.root_nanbox_f64(options); + let key = scope.root_string_ptr(js_string_from_bytes(key.as_ptr(), key.len() as u32)); + if crate::proxy::js_proxy_is_proxy(options.get_nanbox_f64()) != 0 { + return crate::proxy::js_proxy_get( + options.get_nanbox_f64(), + key.with_mut_ptr(|key| f64::from_bits(JSValue::string_ptr(key).bits())), + ); + } + let Some(obj) = object_ptr_from_value(options.get_nanbox_f64()) else { + return undefined(); + }; + key.with_const_ptr(|key| js_object_get_field_by_name_f64(obj, key)) +} diff --git a/crates/perry-runtime/src/intl/segmenter.rs b/crates/perry-runtime/src/intl/segmenter.rs index 52d59bac24..6ba71870ec 100644 --- a/crates/perry-runtime/src/intl/segmenter.rs +++ b/crates/perry-runtime/src/intl/segmenter.rs @@ -178,18 +178,76 @@ pub(crate) fn build_segments(granularity: &str, value: f64) -> f64 { index = end; } } - let segments = arr as *mut ObjectHeader; - set_internal_field(segments, KEY_SEGMENTS_BRAND, string_value(SEGMENTS_BRAND)); - set_internal_field(segments, KEY_SEGMENTS_LENGTH, index as f64); - install_function( - segments, - "containing", - segmenter_containing_thunk as *const u8, - 1, - 1, - false, - ); - js_nanbox_pointer(arr as i64) + let scope = crate::gc::RuntimeHandleScope::new(); + let segments = scope.root_raw_mut_ptr(arr as *mut ObjectHeader); + let brand = string_value(SEGMENTS_BRAND); + segments.with_mut_ptr(|segments| set_internal_field(segments, KEY_SEGMENTS_BRAND, brand)); + segments + .with_mut_ptr(|segments| set_internal_field(segments, KEY_SEGMENTS_LENGTH, index as f64)); + segments.with_mut_ptr(|segments| { + install_function( + segments, + "containing", + segmenter_containing_thunk as *const u8, + 1, + 1, + false, + ) + }); + install_segments_iterator(&segments); + segments.with_mut_ptr(|segments: *mut ObjectHeader| js_nanbox_pointer(segments as i64)) +} + +fn install_segments_iterator(segments: &crate::gc::RuntimeHandle<'_>) { + let symbol = crate::symbol::well_known_symbol("iterator"); + if symbol.is_null() { + return; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let symbol = scope.root_raw_mut_ptr(symbol); + let closure = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( + segments_iterator_thunk as *const u8, + 0, + )); + if closure.with_mut_ptr(|closure: *mut ClosureHeader| closure.is_null()) { + return; + } + crate::closure::js_register_closure_arity(segments_iterator_thunk as *const u8, 0); + closure.with_mut_ptr::(|ptr| { + crate::object::set_bound_native_closure_name(ptr, "[Symbol.iterator]") + }); + closure.with_mut_ptr::(|ptr| { + crate::object::set_builtin_closure_length(ptr as usize, 0) + }); + let value = closure.with_mut_ptr::(|ptr| js_nanbox_pointer(ptr as i64)); + unsafe { + segments.with_mut_ptr(|segments: *mut ObjectHeader| { + symbol.with_const_ptr(|symbol: *const u8| { + crate::symbol::js_object_set_symbol_property( + js_nanbox_pointer(segments as i64), + f64::from_bits(JSValue::pointer(symbol).bits()), + value, + ) + }) + }); + } + segments.with_mut_ptr(|segments: *mut ObjectHeader| { + symbol.with_const_ptr(|symbol: *const u8| { + crate::symbol::set_symbol_property_attrs( + segments as usize, + symbol as usize, + PropertyAttrs::new(true, false, true), + ) + }) + }); +} + +extern "C" fn segments_iterator_thunk(_closure: *const ClosureHeader) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let segments = scope.root_raw_const_ptr(segments_from_this()); + segments.with_const_ptr(|segments: *const crate::ArrayHeader| { + crate::array::array_values_iter(js_nanbox_pointer(segments as i64)) + }) } fn segments_from_this() -> *const crate::ArrayHeader { diff --git a/crates/perry-runtime/src/native_abi.rs b/crates/perry-runtime/src/native_abi.rs index fdac3138e7..7141671220 100644 --- a/crates/perry-runtime/src/native_abi.rs +++ b/crates/perry-runtime/src/native_abi.rs @@ -19,6 +19,13 @@ fn throw_type_error(message: &str) -> ! { crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } +#[cold] +fn throw_range_error(message: &str) -> ! { + let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let err = crate::error::js_rangeerror_new(msg); + crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) +} + fn strict_number(value: f64, message: &str) -> f64 { let js_value = JSValue::from_bits(value.to_bits()); if js_value.is_int32() { @@ -198,6 +205,33 @@ static KEEP_JS_TYPED_STRING_ARG_GUARD: extern "C" fn(f64) -> i32 = js_typed_stri #[used] static KEEP_JS_TYPED_STRING_ARG_TO_RAW: extern "C" fn(f64) -> i64 = js_typed_string_arg_to_raw; +// Manifest calls are emitted from generated LLVM IR and therefore have no +// Rust call graph edge in a release staticlib. Keep every newly public exact- +// width boundary helper alive under whole-program LTO/dead stripping. +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_I8: extern "C" fn(f64) -> i8 = js_native_abi_check_i8; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_I16: extern "C" fn(f64) -> i16 = js_native_abi_check_i16; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_U8: extern "C" fn(f64) -> u8 = js_native_abi_check_u8; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_U16: extern "C" fn(f64) -> u16 = js_native_abi_check_u16; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_CHECK_ISIZE: extern "C" fn(f64) -> isize = js_native_abi_check_isize; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_MATERIALIZE_I64: extern "C" fn(i64) -> f64 = + js_native_abi_materialize_i64; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_NATIVE_ABI_MATERIALIZE_U64: extern "C" fn(u64) -> f64 = + js_native_abi_materialize_u64; + // Static-name and static-method lowering emits these by-id wrappers directly // from generated LLVM IR. Keep roots here so LTO cannot strip the symbols just // because the Rust crate graph has no ordinary caller. @@ -245,6 +279,26 @@ pub extern "C" fn js_native_abi_check_i32(value: f64) -> i32 { number as i32 } +/// Validate and lower a manifest `i8` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_i8(value: f64) -> i8 { + let number = strict_integer(value, "Expected integer for native i8 parameter"); + if number < i8::MIN as f64 || number > i8::MAX as f64 { + throw_type_error("Native i8 parameter is out of range"); + } + number as i8 +} + +/// Validate and lower a manifest `i16` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_i16(value: f64) -> i16 { + let number = strict_integer(value, "Expected integer for native i16 parameter"); + if number < i16::MIN as f64 || number > i16::MAX as f64 { + throw_type_error("Native i16 parameter is out of range"); + } + number as i16 +} + /// Validate and lower a manifest `i64` parameter. #[no_mangle] pub extern "C" fn js_native_abi_check_i64(value: f64) -> i64 { @@ -265,6 +319,26 @@ pub extern "C" fn js_native_abi_check_u32(value: f64) -> u32 { number as u32 } +/// Validate and lower a manifest `u8`/`byte` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_u8(value: f64) -> u8 { + let number = strict_integer(value, "Expected integer for native u8 parameter"); + if number < 0.0 || number > u8::MAX as f64 { + throw_type_error("Native u8 parameter is out of range"); + } + number as u8 +} + +/// Validate and lower a manifest `u16` parameter. +#[no_mangle] +pub extern "C" fn js_native_abi_check_u16(value: f64) -> u16 { + let number = strict_integer(value, "Expected integer for native u16 parameter"); + if number < 0.0 || number > u16::MAX as f64 { + throw_type_error("Native u16 parameter is out of range"); + } + number as u16 +} + /// Validate and lower a manifest `u64` parameter. #[no_mangle] pub extern "C" fn js_native_abi_check_u64(value: f64) -> u64 { @@ -285,6 +359,30 @@ pub extern "C" fn js_native_abi_check_usize(value: f64) -> usize { number as usize } +/// Validate and lower a manifest `isize` parameter on 64-bit native targets. +#[no_mangle] +pub extern "C" fn js_native_abi_check_isize(value: f64) -> isize { + strict_safe_integer(value, "Expected safe integer for native isize parameter") as isize +} + +/// Materialize a signed 64-bit native value without silently rounding it. +#[no_mangle] +pub extern "C" fn js_native_abi_materialize_i64(value: i64) -> f64 { + if value < MIN_SAFE_INTEGER as i64 || value > MAX_SAFE_INTEGER as i64 { + throw_range_error("Native i64/isize value cannot be represented exactly as a number"); + } + value as f64 +} + +/// Materialize an unsigned 64-bit native value without silently rounding it. +#[no_mangle] +pub extern "C" fn js_native_abi_materialize_u64(value: u64) -> f64 { + if value > MAX_SAFE_INTEGER as u64 { + throw_range_error("Native u64/usize value cannot be represented exactly as a number"); + } + value as f64 +} + /// Validate a manifest `string` parameter and return a raw StringHeader pointer. #[no_mangle] pub extern "C" fn js_native_abi_check_string_ptr(value: f64) -> i64 { @@ -388,19 +486,44 @@ mod tests { #[test] fn scalar_guards_reject_incompatible_js_values() { + assert_eq!(js_native_abi_check_i8(-128.0), -128); + assert_eq!(js_native_abi_check_i16(32_767.0), 32_767); assert_eq!(js_native_abi_check_i32(12.0), 12); + assert_eq!(js_native_abi_check_u8(255.0), 255); + assert_eq!(js_native_abi_check_u16(65_535.0), 65_535); assert_eq!(js_native_abi_check_u32(4_000_000_000.0), 4_000_000_000); + assert_eq!(js_native_abi_check_isize(-42.0), -42); + assert_eq!( + js_native_abi_materialize_i64(MIN_SAFE_INTEGER as i64), + MIN_SAFE_INTEGER + ); + assert_eq!( + js_native_abi_materialize_u64(MAX_SAFE_INTEGER as u64), + MAX_SAFE_INTEGER + ); assert_eq!(js_native_abi_check_f32(6.25), 6.25f32); assert!(catch_runtime_throw(|| { js_native_abi_check_i32(1.5); })); + assert!(catch_runtime_throw(|| { + js_native_abi_check_i8(128.0); + })); + assert!(catch_runtime_throw(|| { + js_native_abi_check_u16(-1.0); + })); assert!(catch_runtime_throw(|| { js_native_abi_check_u32(-1.0); })); assert!(catch_runtime_throw(|| { js_native_abi_check_i64(MAX_SAFE_INTEGER + 2.0); })); + assert!(catch_runtime_throw(|| { + js_native_abi_materialize_i64(MAX_SAFE_INTEGER as i64 + 1); + })); + assert!(catch_runtime_throw(|| { + js_native_abi_materialize_u64(MAX_SAFE_INTEGER as u64 + 1); + })); assert!(catch_runtime_throw(|| { let s = crate::string::js_string_from_bytes(b"no".as_ptr(), 2); js_native_abi_check_f64(f64::from_bits(JSValue::string_ptr(s).bits())); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 07f843298d..f318f14910 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -65,9 +65,9 @@ pub(crate) use state::{ class_parent_closure, class_parent_closure_root_store, class_prototype_method_is_enumerable, class_prototype_method_set_enumerable, class_prototype_method_value_cache_root_store, class_prototype_object_root_store, class_static_defined_attrs, class_static_set_defined_attrs, - global_object_prototype_bits, is_bound_native_constructor_closure_value, - is_non_constructable_builtin_function_value, parent_closure_in_chain, - throw_non_constructable_builtin_function, + class_unmark_key_deleted, global_object_prototype_bits, + is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, + parent_closure_in_chain, throw_non_constructable_builtin_function, }; pub use state::{ ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, CLASS_DYNAMIC_PARENT_VALUE, @@ -104,12 +104,15 @@ pub(crate) use class_meta::{ CLASS_ID_TEXT_ENCODER_STREAM, }; #[cfg(test)] -pub(crate) use prototype_methods::CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED; +pub(crate) use prototype_methods::{ + class_prototype_fast_guards_invalidated, class_prototype_method_guard_slot, + CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED, CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD, +}; // ── prototype_methods.rs ──────────────────────────────────────────────────── pub(crate) use prototype_methods::{ - class_prototype_fast_guards_invalidated, class_prototype_method_root_store, - invalidate_class_prototype_fast_guards, mirror_prototype_method_on_object, + class_prototype_fast_guard_invalidated_for_method, class_prototype_method_root_store, + invalidate_class_prototype_fast_guards_for_method, mirror_prototype_method_on_object, synthetic_class_id_for_function, }; pub use prototype_methods::{ @@ -120,9 +123,9 @@ pub use prototype_methods::{ // ── construct.rs / vm_brand.rs ────────────────────────────────────────────── pub(crate) use construct::{ extends_target_must_throw, is_callable_function_value, js_value_is_constructor, - lookup_prototype_method, nm_ctor_child_process, nm_ctor_cluster, nm_ctor_fs, nm_ctor_readline, - nm_ctor_repl, nm_ctor_stream, nm_ctor_tls, nm_ctor_tty, nm_ctor_vm, nm_ctor_wasi, - promise_parent_in_chain, + lookup_own_prototype_method, lookup_prototype_method, nm_ctor_child_process, nm_ctor_cluster, + nm_ctor_fs, nm_ctor_readline, nm_ctor_repl, nm_ctor_stream, nm_ctor_tls, nm_ctor_tty, + nm_ctor_vm, nm_ctor_wasi, promise_parent_in_chain, }; pub use construct::{ js_ctor_return_override, js_new_function_construct, js_new_function_construct_apply, diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index d7a5940205..f66cd9dc43 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -1862,6 +1862,17 @@ pub(super) fn is_arrow_function_value(value: f64) -> bool { /// `(class_id, name)`, or None if no assignment matched. Walks the /// parent-class chain so methods registered on a base class are found /// via subclass instances. +pub(crate) fn lookup_own_prototype_method(class_id: u32, name: &str) -> Option { + if class_is_key_deleted(class_id, name) { + return None; + } + CLASS_PROTOTYPE_METHODS.with(|table| { + let guard = table.read().ok()?; + let bits = guard.as_ref()?.get(&class_id)?.get(name)?; + Some(f64::from_bits(*bits)) + }) +} + pub(crate) fn lookup_prototype_method(class_id: u32, name: &str) -> Option { CLASS_PROTOTYPE_METHODS.with(|table| { let guard = table.read().ok()?; @@ -1869,9 +1880,11 @@ pub(crate) fn lookup_prototype_method(class_id: u32, name: &str) -> Option let mut cid = class_id; let mut depth = 0usize; while depth < 32 { - if let Some(per_class) = map.get(&cid) { - if let Some(&bits) = per_class.get(name) { - return Some(f64::from_bits(bits)); + if !class_is_key_deleted(cid, name) { + if let Some(per_class) = map.get(&cid) { + if let Some(&bits) = per_class.get(name) { + return Some(f64::from_bits(bits)); + } } } match crate::object::class_generic_origin(cid).or_else(|| get_parent_class_id(cid)) { diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index 9cc418b72b..f514a8bf27 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -622,6 +622,10 @@ pub(crate) fn test_clear_class_side_table_roots() { } }); CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.store(false, std::sync::atomic::Ordering::Release); + CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD + .write() + .unwrap() + .clear(); FUNCTION_CLASS_IDS.with(|table| { if let Ok(mut guard) = table.write() { *guard = None; diff --git a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs index d09c74d746..919bbca739 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs @@ -100,19 +100,36 @@ crate::perry_thread_local! { RwLock::new(None); } -// Production codegen reads this byte directly before entering a guarded -// direct-method arm. Keep the test build per-thread so one mutation test cannot -// poison unrelated tests running in parallel; generated programs link the -// non-test symbol below. +// Production codegen reads this fail-closed all-method byte and the scoped +// table below before entering a guarded direct-method arm. Keep the test state +// per-thread so one mutation test cannot poison unrelated tests running in +// parallel; generated programs link the non-test symbols below. #[cfg(not(test))] #[no_mangle] pub static PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); +/// Sticky per-method invalidation bytes for compiler-emitted direct-method +/// guards. Prototype writes always have a property name, so they only need to +/// retire guards for that name. The low 16 bits of the name's FNV-1a hash +/// select a byte; collisions conservatively retire additional names. +pub(crate) const CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT: usize = 1 << 16; +pub(crate) const CLASS_PROTOTYPE_METHOD_GUARD_SLOT_MASK: u64 = + (CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT - 1) as u64; + +#[cfg(not(test))] +#[no_mangle] +pub static PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD: [std::sync::atomic::AtomicU8; + CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT] = + [const { std::sync::atomic::AtomicU8::new(0) }; CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT]; + #[cfg(test)] per_test_global! { pub(crate) static CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + pub(crate) static CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD: + std::sync::RwLock> = + std::sync::RwLock::new(std::collections::HashSet::new()); } pub(crate) fn class_prototype_fast_guards_invalidated() -> bool { @@ -127,30 +144,68 @@ pub(crate) fn class_prototype_fast_guards_invalidated() -> bool { } } +#[inline] +pub(crate) fn class_prototype_method_guard_slot(name: &str) -> u32 { + (super::super::key_bytes_hash(name.as_ptr(), name.len()) + & CLASS_PROTOTYPE_METHOD_GUARD_SLOT_MASK) as u32 +} + +#[inline] +pub(crate) fn class_prototype_fast_guard_invalidated_for_method(slot: u32) -> bool { + if class_prototype_fast_guards_invalidated() { + return true; + } + let slot = (slot as usize) & (CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT - 1); + #[cfg(not(test))] + { + PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD[slot] + .load(std::sync::atomic::Ordering::Acquire) + != 0 + } + #[cfg(test)] + { + CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD + .read() + .unwrap() + .contains(&(slot as u16)) + } +} + +#[inline] +fn retire_prototype_dependent_caches() { + // #7480: prototype surgery retires element-shape proofs. + crate::array::invalidate_all_element_shapes(); + // #7769: method-dispatch caches are keyed by VTABLE_GEN. + VTABLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release); +} + +pub(crate) fn invalidate_class_prototype_fast_guards_for_method(name: &str) { + let slot = class_prototype_method_guard_slot(name) as usize; + #[cfg(not(test))] + PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD[slot] + .store(1, std::sync::atomic::Ordering::Release); + #[cfg(test)] + CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD + .write() + .unwrap() + .insert(slot as u16); + retire_prototype_dependent_caches(); +} + +#[allow(dead_code)] // Fail-closed escape hatch for a future keyless mutation path. pub(crate) fn invalidate_class_prototype_fast_guards() { #[cfg(not(test))] PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.store(1, std::sync::atomic::Ordering::Release); #[cfg(test)] CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.store(true, std::sync::atomic::Ordering::Release); - // #7480: prototype surgery is the one event that retires an element-shape - // proof without touching any array — the class's shape stopped being a - // reliable description of its instances. This is the existing single - // latch all three prototype-write entry points funnel through - // (`js_register_prototype_method`, `class_prototype_method_root_store`, - // and the class-registry state path), so one generation bump here retires - // every outstanding record at O(1). - crate::array::invalidate_all_element_shapes(); - // #7769: prototype surgery can change which member a `recv.m()` resolves - // to, and the method-dispatch caches (`vtable_ic`, `obj_dispatch_ic`) key - // their entries on `VTABLE_GEN`. Those caches were only retired by class - // REGISTRATION, so a `Class.prototype.m = fn` after first dispatch left - // them serving the pre-surgery answer. Retire them here, at the one latch - // all three prototype-write entry points funnel through — the same O(1) - // argument as the element-shape invalidation above. - VTABLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release); + // Unknown-key prototype surgery cannot use a scoped slot. Retire every + // direct-method guard, then perform the common cache invalidations. + retire_prototype_dependent_caches(); } pub(crate) fn class_prototype_method_root_store(class_id: u32, name: String, value_bits: u64) { + // Assignment after `delete C.prototype.m` creates the property again. + class_unmark_key_deleted(class_id, &name); CLASS_PROTOTYPE_METHODS.with(|table| { let mut guard = table.write().unwrap(); if guard.is_none() { @@ -163,7 +218,7 @@ pub(crate) fn class_prototype_method_root_store(class_id: u32, name: String, val .or_default() .insert(name.clone(), value_bits); }); - invalidate_class_prototype_fast_guards(); + invalidate_class_prototype_fast_guards_for_method(&name); crate::gc::runtime_write_barrier_root_nanbox(value_bits); // #5024: the side table makes the method dispatchable, but own-key // enumeration on the prototype OBJECT (Object.keys / getOwnPropertyNames / @@ -250,7 +305,6 @@ pub unsafe extern "C" fn js_register_prototype_method( name_len: usize, value: f64, ) { - invalidate_class_prototype_fast_guards(); if class_id == 0 || name_ptr.is_null() || name_len == 0 { return; } diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index bebfa9038b..6ace1cbc6d 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -43,6 +43,14 @@ pub(crate) fn class_is_key_deleted(class_id: u32, key: &str) -> bool { }) } +pub(crate) fn class_unmark_key_deleted(class_id: u32, key: &str) { + CLASS_DELETED_KEYS.with(|m| { + if let Some(keys) = m.borrow_mut().get_mut(&class_id) { + keys.remove(key); + } + }); +} + /// Record `C. = value` in the class-ref side table that dynamic reads /// (`const K: any = C; K.name`, `Object.keys(C)`, `getOwnPropertyDescriptor`) /// consult, and shade the stored value for the incremental marker. @@ -648,18 +656,9 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { return f64::from_bits(crate::value::TAG_UNDEFINED); } // #7769 follow-up: materializing a declared class's prototype object is - // not prototype SURGERY, and it used to invalidate the fast guards as if - // it were. - // - // `invalidate_class_prototype_fast_guards()` trips a process-global, - // MONOTONIC latch. It disables every `js_method_direct_shape_guard` / - // `js_typed_feedback_method_direct_call_guard` in the program, retires - // every outstanding element-shape record (`invalidate_all_element_shapes`) - // and bumps `VTABLE_GEN`, retiring the `vtable_ic` / `obj_dispatch_ic` - // dispatch caches. It exists for the one event that can change which - // member `recv.m()` resolves to: a WRITE to a prototype - // (`Class.prototype.m = fn`), which is what the two call sites in - // `prototype_methods.rs` cover. + // not prototype surgery. A real keyed prototype write invalidates only + // the matching method-name guard slot, retires element-shape records, and + // bumps `VTABLE_GEN` so generic dispatch observes the replacement. // // Reaching this line changes none of that. The object being created is // fresh and unobserved; the writes immediately below install @@ -727,12 +726,35 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { let parent_bits = parent_proto.to_bits(); ((parent_bits >> 48) == 0x7FFD).then_some(parent_bits) }) + // A runtime function-valued superclass (including Intl service + // constructors) has no class-id edge. Link the declared prototype + // to the parent's own `.prototype` exactly once, while this fresh + // class prototype is initialized. Construction must never rewrite + // this edge after user code mutates it. + .or_else(|| { + let parent = JSValue::from_bits(dynamic_parent.to_bits()); + if !parent.is_pointer() { + return None; + } + let parent_addr = parent.as_pointer::() as usize; + if !crate::closure::is_closure_ptr(parent_addr) { + return None; + } + let parent_proto = + crate::closure::closure_get_dynamic_prop(parent_addr, "prototype"); + let bits = parent_proto.to_bits(); + ((bits >> 48) == 0x7FFD).then_some(bits) + }) .or_else(global_object_prototype_bits) }; if let Some(bits) = parent_proto_bits { - super::super::prototype_chain::object_set_static_prototype(proto as usize, bits); + let proto = class_decl_prototype_object(class_id); + if !proto.is_null() { + super::super::prototype_chain::object_set_static_prototype(proto as usize, bits); + } } + let proto = class_decl_prototype_object(class_id); crate::value::js_nanbox_pointer(proto as i64) } diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 4b7015e949..0efd8fc607 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -237,6 +237,12 @@ pub extern "C" fn js_object_delete_field( return 0; } } + // Deleting an accessor from a class/Object prototype changes + // method resolution for this key just like installing it. + super::descriptor_state::disable_inline_guards_for_descriptor_target( + obj as usize, + name, + ); super::clear_accessor_descriptor(obj as usize, name); super::clear_property_attrs(obj as usize, name); // defineProperty may ALSO have planted a keys_array @@ -308,6 +314,12 @@ pub extern "C" fn js_object_delete_field( return 0; } } + // A configurable data method on a class/Object prototype is about + // to disappear. Retire only this name's direct-method guards. + super::descriptor_state::disable_inline_guards_for_descriptor_target( + obj as usize, + name, + ); } // Proper delete: shift remaining keys + values down by one, then diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index 53b5c1d234..462f08099f 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -200,11 +200,11 @@ pub(crate) fn disable_inline_guards_for_descriptor_target(obj: usize, key: &str) || class_registry::is_registered_class_prototype_object(obj) || class_registry::class_id_for_decl_prototype_object(obj).is_some(); if is_prototype_target { - // Direct method guards are not key-aware. Conservatively retire them - // after any user descriptor/accessor install on a prototype that can - // affect a class instance. Own-instance installs are rejected by the - // receiver's `OBJ_FLAG_HAS_DESCRIPTORS` header bit instead. - class_registry::invalidate_class_prototype_fast_guards(); + // A prototype descriptor can only change resolution for this key. + // Retire the matching method-name guard slot across all classes; + // own-instance installs are still rejected by the receiver's + // `OBJ_FLAG_HAS_DESCRIPTORS` header bit. + class_registry::invalidate_class_prototype_fast_guards_for_method(key); let hash = super::key_bytes_hash(key.as_ptr(), key.len()); note_proto_descriptor_key_hash(hash); if declared_field_name_hash_exists(hash) { @@ -1187,8 +1187,18 @@ mod c5a_tests { the inline class-field fast path" ); assert!( - class_registry::class_prototype_fast_guards_invalidated(), - "a prototype descriptor must retire unkeyed direct-method guards" + !class_registry::class_prototype_fast_guards_invalidated(), + "a keyed prototype descriptor must not retire every method guard" + ); + let render_slot = class_registry::class_prototype_method_guard_slot("c5a_render_method"); + assert!( + class_registry::class_prototype_fast_guard_invalidated_for_method(render_slot), + "a prototype descriptor must retire its matching method guard" + ); + let other_slot = class_registry::class_prototype_method_guard_slot("c5a_other_method"); + assert!( + !class_registry::class_prototype_fast_guard_invalidated_for_method(other_slot), + "an unrelated method guard must remain valid" ); // Field-style install: key declared by a registered class. diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 8848bb91da..7d3eddd4dc 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -2059,7 +2059,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // Vtable lookup: check if this class has a registered method in the vtable let class_id = (*obj).class_id; - if class_id != 0 { + if class_id != 0 && !class_is_key_deleted(class_id, method_name) { if let Ok(registry) = CLASS_VTABLE_REGISTRY.read() { if let Some(ref reg) = *registry { if let Some(vtable) = reg.get(&class_id) { diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index d4c150ae9c..aec8c9e67f 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -1017,45 +1017,65 @@ pub(super) unsafe fn dispatch_handle( let mut cur_cid = class_id; let mut depth = 0u32; while depth < 32 { - if let Some(vtable) = reg.get(&cur_cid) { - if let Some(entry) = vtable.methods.get(method_name) { - vtable_ic_insert( - class_id, - method_name_ptr as usize, - entry.func_ptr, - entry.param_count, - entry.has_synthetic_arguments, - entry.has_rest, - ); - // #7769: this walk — not the tail vtable - // arm of `js_native_call_method` — is where - // an INHERITED method resolves, and - // inherited methods are the common case in - // any real hierarchy (`class Square extends - // Rect` calling `Rect`'s `area`). Recording - // the outcome here is what lets the - // top-of-tower fast path serve them; the - // helper re-checks the receiver-shape - // predicate before storing anything. - super::note_class_vtable_resolution( - f64::from_bits(jsval.bits()), - method_name, - entry.func_ptr, - entry.param_count, - entry.has_synthetic_arguments, - entry.has_rest, - ); - resolved_method = Some(ResolvedMethod::Vtable { - func_ptr: entry.func_ptr, - param_count: entry.param_count, - has_synthetic_arguments: entry.has_synthetic_arguments, - has_rest: entry.has_rest, - this_i64: jsval.as_pointer::() as i64, + let deleted = class_is_key_deleted(cur_cid, method_name); + // `C.prototype.m = fn` replaces a declared `m` on + // this exact prototype object, so the assignment + // side table must win before the original vtable. + if !deleted { + if let Some(method_value) = + lookup_own_prototype_method(cur_cid, method_name) + { + resolved_method = Some(ResolvedMethod::ProtoClosure { + field_bits: method_value.to_bits(), }); break; } } - let proto_obj = class_prototype_object(cur_cid); + if !deleted { + if let Some(vtable) = reg.get(&cur_cid) { + if let Some(entry) = vtable.methods.get(method_name) { + vtable_ic_insert( + class_id, + method_name_ptr as usize, + entry.func_ptr, + entry.param_count, + entry.has_synthetic_arguments, + entry.has_rest, + ); + // #7769: this walk — not the tail vtable + // arm of `js_native_call_method` — is where + // an INHERITED method resolves, and + // inherited methods are the common case in + // any real hierarchy (`class Square extends + // Rect` calling `Rect`'s `area`). Recording + // the outcome here is what lets the + // top-of-tower fast path serve them; the + // helper re-checks the receiver-shape + // predicate before storing anything. + super::note_class_vtable_resolution( + f64::from_bits(jsval.bits()), + method_name, + entry.func_ptr, + entry.param_count, + entry.has_synthetic_arguments, + entry.has_rest, + ); + resolved_method = Some(ResolvedMethod::Vtable { + func_ptr: entry.func_ptr, + param_count: entry.param_count, + has_synthetic_arguments: entry.has_synthetic_arguments, + has_rest: entry.has_rest, + this_i64: jsval.as_pointer::() as i64, + }); + break; + } + } + } + let proto_obj = if deleted { + std::ptr::null_mut() + } else { + class_prototype_object(cur_cid) + }; if !proto_obj.is_null() { let method_key = crate::string::js_string_from_bytes( method_name.as_ptr(), diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index c9045638ff..e5e459f858 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -1036,14 +1036,15 @@ pub unsafe extern "C" fn js_typed_feedback_method_direct_call_guard( /// /// Descriptor invalidation is deliberately scoped rather than process-wide: /// an own descriptor sets `OBJ_FLAG_HAS_DESCRIPTORS` on this receiver, while a -/// user descriptor on a registered class/Object prototype flips the same -/// sticky prototype latch checked below. A descriptor on an unrelated object -/// can affect neither method resolution nor this exact ShapeId proof and must -/// not poison every direct-method site in the process. +/// user descriptor on a registered class/Object prototype flips the matching +/// method-name slot checked below. A descriptor on an unrelated object or for +/// an unrelated key can affect neither this method's resolution nor this exact +/// ShapeId proof and must not poison every direct-method site in the process. #[no_mangle] pub unsafe extern "C" fn js_method_direct_shape_class( receiver: f64, out_shape_id: *mut u32, + method_guard_slot: u32, ) -> u32 { if !out_shape_id.is_null() { *out_shape_id = 0; @@ -1058,7 +1059,7 @@ pub unsafe extern "C" fn js_method_direct_shape_class( if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT || (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 || (*gc_header)._reserved & crate::gc::OBJ_FLAG_HAS_DESCRIPTORS != 0 - || crate::object::class_prototype_fast_guards_invalidated() + || crate::object::class_prototype_fast_guard_invalidated_for_method(method_guard_slot) { return 0; } @@ -1089,12 +1090,13 @@ pub unsafe extern "C" fn js_method_direct_shape_guard( receiver: f64, expected_class_id: u32, expected_shape_id: u32, + method_guard_slot: u32, ) -> i32 { if expected_class_id == 0 || !crate::object::shapes::is_shape_id(expected_shape_id) { return 0; } let mut shape_id = 0; - let class_id = js_method_direct_shape_class(receiver, &mut shape_id); + let class_id = js_method_direct_shape_class(receiver, &mut shape_id, method_guard_slot); (class_id == expected_class_id && shape_id == expected_shape_id) as i32 } @@ -1185,7 +1187,7 @@ mod keep_guard_symbols { #[cfg(feature = "keepalive-anchors")] #[used] static G3: extern "C" fn(u64, f64, *const u8, u32, u32) -> i32 = js_typed_feedback_closure_direct_call_guard; #[cfg(feature = "keepalive-anchors")] - #[used] static G4: unsafe extern "C" fn(f64, u32, u32) -> i32 = js_method_direct_shape_guard; + #[used] static G4: unsafe extern "C" fn(f64, u32, u32, u32) -> i32 = js_method_direct_shape_guard; #[cfg(feature = "keepalive-anchors")] - #[used] static G4B: unsafe extern "C" fn(f64, *mut u32) -> u32 = js_method_direct_shape_class; + #[used] static G4B: unsafe extern "C" fn(f64, *mut u32, u32) -> u32 = js_method_direct_shape_class; } diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 75feab5563..61a6a39c25 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1277,7 +1277,7 @@ fn representation_lowering_helpers_have_lto_keepalive_anchors() { ( guards, "static G4", - "static G4: unsafe extern \"C\" fn(f64, u32, u32) -> i32", + "static G4: unsafe extern \"C\" fn(f64, u32, u32, u32) -> i32", "js_method_direct_shape_guard", ), ( @@ -1981,10 +1981,17 @@ fn method_direct_shape_guard_requires_the_exact_compiler_pair() { let class_id = 0x7EED_1061; let (obj, _, _, receiver) = class_instance(class_id, b"x"); let expected_shape_id = shape_id(obj); + let method_name = "direct_shape_target_1061"; + let method_slot = crate::object::class_prototype_method_guard_slot(method_name); assert_eq!( unsafe { - super::guards::js_method_direct_shape_guard(receiver, class_id, expected_shape_id) + super::guards::js_method_direct_shape_guard( + receiver, + class_id, + expected_shape_id, + method_slot, + ) }, 1 ); @@ -1994,6 +2001,7 @@ fn method_direct_shape_guard_requires_the_exact_compiler_pair() { receiver, class_id.wrapping_add(1), expected_shape_id, + method_slot, ) }, 0 @@ -2011,7 +2019,12 @@ fn method_direct_shape_guard_requires_the_exact_compiler_pair() { ); assert_eq!( unsafe { - super::guards::js_method_direct_shape_guard(receiver, class_id, expected_shape_id) + super::guards::js_method_direct_shape_guard( + receiver, + class_id, + expected_shape_id, + method_slot, + ) }, 1 ); @@ -2023,7 +2036,12 @@ fn method_direct_shape_guard_requires_the_exact_compiler_pair() { let original_reserved = (*gc)._reserved; (*gc)._reserved |= crate::gc::OBJ_FLAG_HAS_DESCRIPTORS; assert_eq!( - super::guards::js_method_direct_shape_guard(receiver, class_id, expected_shape_id), + super::guards::js_method_direct_shape_guard( + receiver, + class_id, + expected_shape_id, + method_slot, + ), 0 ); (*gc)._reserved = original_reserved; @@ -2037,13 +2055,54 @@ fn method_direct_shape_guard_requires_the_exact_compiler_pair() { } assert_eq!( unsafe { - super::guards::js_method_direct_shape_guard(receiver, class_id, expected_shape_id) + super::guards::js_method_direct_shape_guard( + receiver, + class_id, + expected_shape_id, + method_slot, + ) }, 0 ); unsafe { (*obj).parent_class_id = expected_shape_id; } + + crate::object::class_prototype_method_root_store( + class_id.wrapping_add(10), + "direct_shape_unrelated_1061".to_string(), + crate::value::TAG_UNDEFINED, + ); + assert_eq!( + unsafe { + super::guards::js_method_direct_shape_guard( + receiver, + class_id, + expected_shape_id, + method_slot, + ) + }, + 1, + "a different method name must not poison this direct guard", + ); + + crate::object::class_prototype_method_root_store( + class_id.wrapping_add(11), + method_name.to_string(), + crate::value::TAG_UNDEFINED, + ); + assert_eq!( + unsafe { + super::guards::js_method_direct_shape_guard( + receiver, + class_id, + expected_shape_id, + method_slot, + ) + }, + 0, + "the same method name must retire guards across the class hierarchy", + ); } #[test] diff --git a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs index 7d942e59b2..3c9bd83f50 100644 --- a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs +++ b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs @@ -194,7 +194,7 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg // both-archives link — the registration is dropped and the socket's // 'data' events never reach JS. Use ext-net's distinct symbols. fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb_ptr: i64); - fn js_net_socket_method_connect(handle: i64, port: f64, host_ptr: i64); + fn js_ext_net_socket_method_connect(handle: i64, arg1: f64, arg2: f64, arg3: f64); fn js_net_socket_upgrade_tls( handle: i64, servername_ptr: i64, @@ -267,11 +267,12 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg js_ext_net_socket_on(handle, event_ptr, cb_ptr); nanbox_handle(handle) } - "connect" if args.len() >= 2 => { - let port = args[0]; - let host_ptr = unbox_to_i64(args[1]); - js_net_socket_method_connect(handle, port, host_ptr); - f64::from_bits(0x7FFC_0000_0000_0001) + "connect" if !args.is_empty() => { + let undefined = f64::from_bits(0x7FFC_0000_0000_0001); + let arg2 = args.get(1).copied().unwrap_or(undefined); + let arg3 = args.get(2).copied().unwrap_or(undefined); + js_ext_net_socket_method_connect(handle, args[0], arg2, arg3); + nanbox_handle(handle) } "upgradeToTLS" if !args.is_empty() => { let servername_ptr = unbox_to_i64(args[0]); diff --git a/crates/perry-stdlib/src/net/mod.rs b/crates/perry-stdlib/src/net/mod.rs index b24c6b793d..66de8a8254 100644 --- a/crates/perry-stdlib/src/net/mod.rs +++ b/crates/perry-stdlib/src/net/mod.rs @@ -543,6 +543,7 @@ unsafe fn get_object_bool_field(obj_f64: f64, field_name: &str) -> Option /// mirrors `crates/perry-stdlib/src/sqlite.rs::build_packed_keys`. unsafe fn build_error_object(msg: &str) -> f64 { use perry_runtime::JSValue; + let scope = perry_runtime::gc::RuntimeHandleScope::new(); let keys = ["message", "code", "name"]; let mut packed = Vec::new(); for key in keys { @@ -554,17 +555,27 @@ unsafe fn build_error_object(msg: &str) -> f64 { shape_id = shape_id.wrapping_mul(31).wrapping_add(b as u32); } shape_id = shape_id.wrapping_add(3); - let s_msg = perry_runtime::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let obj = perry_runtime::js_object_alloc_with_shape( + let s_msg = scope.root_string_ptr(perry_runtime::js_string_from_bytes( + msg.as_ptr(), + msg.len() as u32, + )); + let obj_ptr = perry_runtime::js_object_alloc_with_shape( shape_id, 3, packed.as_ptr(), packed.len() as u32, ); - if obj.is_null() { - return f64::from_bits(0x7FFF_0000_0000_0000u64 | (s_msg as u64 & 0x0000_FFFF_FFFF_FFFF)); + if obj_ptr.is_null() { + return s_msg.with_const_ptr(|s_msg: *const perry_runtime::StringHeader| { + f64::from_bits(0x7FFF_0000_0000_0000u64 | (s_msg as u64 & 0x0000_FFFF_FFFF_FFFF)) + }); } - perry_runtime::js_object_set_field(obj, 0, JSValue::string_ptr(s_msg)); + let obj = scope.root_raw_mut_ptr(obj_ptr); + obj.with_mut_ptr(|obj| { + s_msg.with_mut_ptr(|s_msg| { + perry_runtime::js_object_set_field(obj, 0, JSValue::string_ptr(s_msg)) + }) + }); let code = if msg.starts_with("ERR_") { Some(msg) } else if msg.contains("UnknownIssuer") @@ -578,13 +589,25 @@ unsafe fn build_error_object(msg: &str) -> f64 { None }; if let Some(code) = code { - let code = perry_runtime::js_string_from_bytes(code.as_ptr(), code.len() as u32); - perry_runtime::js_object_set_field(obj, 1, JSValue::string_ptr(code)); + let code = scope.root_string_ptr(perry_runtime::js_string_from_bytes( + code.as_ptr(), + code.len() as u32, + )); + obj.with_mut_ptr(|obj| { + code.with_mut_ptr(|code| { + perry_runtime::js_object_set_field(obj, 1, JSValue::string_ptr(code)) + }) + }); } - let name = perry_runtime::js_string_from_bytes(b"Error".as_ptr(), 5); - perry_runtime::js_object_set_field(obj, 2, JSValue::string_ptr(name)); - let obj_bits = (obj as u64 & 0x0000_FFFF_FFFF_FFFF) | 0x7FFD_0000_0000_0000; - f64::from_bits(obj_bits) + let name = scope.root_string_ptr(perry_runtime::js_string_from_bytes(b"Error".as_ptr(), 5)); + obj.with_mut_ptr(|obj| { + name.with_mut_ptr(|name| { + perry_runtime::js_object_set_field(obj, 2, JSValue::string_ptr(name)) + }) + }); + obj.with_mut_ptr(|obj: *mut perry_runtime::ObjectHeader| { + f64::from_bits((obj as u64 & 0x0000_FFFF_FFFF_FFFF) | 0x7FFD_0000_0000_0000) + }) } fn next_id() -> i64 { diff --git a/crates/perry-transform/src/async_to_generator_tests.rs b/crates/perry-transform/src/async_to_generator_tests.rs index 7bb4b8759b..c5cec94737 100644 --- a/crates/perry-transform/src/async_to_generator_tests.rs +++ b/crates/perry-transform/src/async_to_generator_tests.rs @@ -6,8 +6,6 @@ use super::*; -use super::*; - // The minimal shape the collect scan matches: `async () => { await 1 }` // — `Expr::Closure { is_async, !is_generator }` whose body has an Await. fn async_closure_with_await(func_id: perry_hir::types::FuncId) -> Expr { diff --git a/crates/perry-transform/src/inline/analysis.rs b/crates/perry-transform/src/inline/analysis.rs index d33c06a0b9..14c3179c62 100644 --- a/crates/perry-transform/src/inline/analysis.rs +++ b/crates/perry-transform/src/inline/analysis.rs @@ -1,4 +1,4 @@ -use perry_hir::types::{FuncId, LocalId}; +use perry_hir::types::{FuncId, LocalId, Type}; use perry_hir::walker::walk_expr_children; use perry_hir::{Class, Expr, Function, Module, Stmt}; use std::collections::{HashMap, HashSet}; @@ -95,6 +95,13 @@ pub fn is_inlinable(func: &Function) -> bool { return false; } + // Standalone POD records are copy values. The ordinary call boundary + // materializes a fresh object for a POD argument, while substitution + // would make writes to the callee parameter target the caller's local. + if has_pod_value_param(func) { + return false; + } + // Don't inline functions that are too large if func.body.len() > MAX_INLINE_STMTS { return false; @@ -331,6 +338,9 @@ pub fn is_inlinable_method(func: &Function) -> bool { if func.params.iter().any(|p| p.is_rest) { return false; } + if has_pod_value_param(func) { + return false; + } if func.body.len() > MAX_INLINE_STMTS { return false; } @@ -353,6 +363,15 @@ pub fn is_inlinable_method(func: &Function) -> bool { true } +fn has_pod_value_param(func: &Function) -> bool { + func.params.iter().any(|param| { + matches!( + ¶m.ty, + Type::Generic { base, type_args } if base == "PerryPod" && type_args.len() == 1 + ) + }) +} + /// Check if `stmts` contains any `Expr::Call { callee: FuncRef(target_id) }`, /// recursively. Stops at closure boundaries — a self-reference inside a nested /// closure is a value-position read, not a same-frame recursive tail, and the diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 50cbf13977..c1f17bc1cc 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -796,7 +796,7 @@ fn inline_functions_inner( #[cfg(test)] mod tests { use super::*; - use perry_hir::{ImportSpecifier, ModuleKind}; + use perry_hir::{ImportSpecifier, ModuleKind, Param}; fn function(id: FuncId, body: Vec) -> Function { Function { @@ -869,6 +869,26 @@ mod tests { }) } + #[test] + fn pod_value_parameters_preserve_the_call_copy_boundary() { + let mut func = function(1, vec![Stmt::Return(None)]); + func.params.push(Param { + id: 1, + name: "value".to_string(), + ty: Type::Generic { + base: "PerryPod".to_string(), + type_args: vec![Type::Object(Default::default())], + }, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }); + + assert!(!is_inlinable(&func)); + assert!(!is_inlinable_method(&func)); + } + #[test] fn cross_module_synthetic_imports_are_sorted() { let mut module = Module::new("dest"); diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index 6be7bed29f..e529a29c0a 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -448,11 +448,13 @@ pub(super) fn detect_optional_feature_usage( { ctx.uses_proc_ipc = true; } - // `Intl.getCanonicalLocales(...)` / `Intl.*.supportedLocalesOf(...)` gate - // `perry-runtime/intl-locale` (`icu_locale_core` BCP-47 canonicalization). - // Both lower with the method name as a `property` token. + // `Intl.Locale`, `Intl.getCanonicalLocales(...)`, and + // `Intl.*.supportedLocalesOf(...)` gate `perry-runtime/intl-locale` + // (ICU4X BCP-47 canonicalization + likely-subtag expansion). These lower + // with the constructor/method name as a `property` token. if hir_debug.contains("property: \"getCanonicalLocales\"") || hir_debug.contains("property: \"supportedLocalesOf\"") + || hir_debug.contains("property: \"Locale\"") { ctx.uses_intl_locale = true; } diff --git a/crates/perry/src/commands/compile/resolve/native_library.rs b/crates/perry/src/commands/compile/resolve/native_library.rs index 53e278a844..fe67d12272 100644 --- a/crates/perry/src/commands/compile/resolve/native_library.rs +++ b/crates/perry/src/commands/compile/resolve/native_library.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, Result}; use perry_api_manifest::{ @@ -625,7 +625,7 @@ fn parse_native_pod_descriptor( value: &serde_json::Value, ) -> Result { let object = value.as_object().expect("pod descriptor is an object"); - let allowed = ["kind", "name", "fields"]; + let allowed = ["kind", "name", "fields", "source"]; for key in object.keys() { if !allowed.contains(&key.as_str()) { return Err(invalid_native_abi_error( @@ -639,7 +639,7 @@ fn parse_native_pod_descriptor( } } - let name = match object.get("name") { + let mut name = match object.get("name") { Some(v) => Some( v.as_str() .filter(|s| !s.trim().is_empty()) @@ -658,23 +658,92 @@ fn parse_native_pod_descriptor( None => None, }; - let fields_value = object.get("fields").ok_or_else(|| { - invalid_native_abi_error( - package_json, - function_index, - function_name, - slot, - &value.to_string(), - "pod descriptor requires a `fields` array", - ) - })?; + let manifest_fields = object + .get("fields") + .map(|fields_value| { + parse_native_pod_fields( + package_json, + function_index, + function_name, + slot, + fields_value, + ) + }) + .transpose()?; + let source_pod = object + .get("source") + .map(|source| { + let source = source.as_str().filter(|s| !s.trim().is_empty()).ok_or_else(|| { + invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + "pod descriptor `source` must be a non-empty relative path and exported type, such as `./src/native.ts#Point`", + ) + })?; + parse_source_pod_contract(package_json, source, name.as_deref()).map_err(|reason| { + invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + &reason, + ) + }) + }) + .transpose()?; + if name.is_none() { + name = source_pod.as_ref().and_then(|pod| pod.name.clone()); + } + + let fields = match (manifest_fields, source_pod) { + (Some(fields), Some(source)) => { + if let Some(reason) = pod_contract_drift(&fields, &source.fields, "") { + return Err(invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + &format!("POD manifest/source contract drift: {reason}"), + )); + } + fields + } + (Some(fields), None) => fields, + (None, Some(source)) => source.fields, + (None, None) => { + return Err(invalid_native_abi_error( + package_json, + function_index, + function_name, + slot, + &value.to_string(), + "pod descriptor requires either a `fields` array or a `source` reference", + )); + } + }; + + Ok(NativeAbiType::Pod(NativePodAbi { name, fields })) +} + +fn parse_native_pod_fields( + package_json: &Path, + function_index: usize, + function_name: &str, + slot: &str, + fields_value: &serde_json::Value, +) -> Result> { let fields_array = fields_value.as_array().ok_or_else(|| { invalid_native_abi_error( package_json, function_index, function_name, slot, - &value.to_string(), + &fields_value.to_string(), "pod descriptor `fields` must be an array", ) })?; @@ -684,7 +753,7 @@ fn parse_native_pod_descriptor( function_index, function_name, slot, - &value.to_string(), + &fields_value.to_string(), "pod descriptor `fields` must contain at least one field", )); } @@ -778,7 +847,7 @@ fn parse_native_pod_descriptor( function_name, &format!("{slot}.fields[{field_index}].type"), &ty.to_string(), - "pod field type must be one of i32, i64, u32, u64, usize, f32, f64, number, buffer_len, handle_id, or nested pod", + "pod field type must be a fixed-width scalar (i8/i16/i32/i64/u8/u16/u32/u64/isize/usize/f32/f64), number, buffer_len, handle_id, or nested pod", )); } fields.push(NativePodFieldAbi { @@ -787,7 +856,224 @@ fn parse_native_pod_descriptor( }); } - Ok(NativeAbiType::Pod(NativePodAbi { name, fields })) + Ok(fields) +} + +fn parse_source_pod_contract( + package_json: &Path, + source_reference: &str, + fallback_name: Option<&str>, +) -> Result { + let (source_path, export_name) = match source_reference.rsplit_once('#') { + Some((path, export)) if !path.is_empty() && !export.is_empty() => (path, export), + Some(_) => { + return Err("pod descriptor `source` must use `#`".into()) + } + None => ( + source_reference, + fallback_name.ok_or_else(|| { + "pod descriptor `source` without `#ExportName` requires a non-empty `name`" + .to_string() + })?, + ), + }; + let relative = Path::new(source_path); + if relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err("pod descriptor `source` must stay inside the native package".into()); + } + let package_dir = package_json + .parent() + .ok_or_else(|| "native package.json has no parent directory".to_string())?; + let canonical_package = package_dir + .canonicalize() + .map_err(|error| format!("could not resolve native package directory: {error}"))?; + let source_file = package_dir.join(relative); + let canonical_source = source_file.canonicalize().map_err(|error| { + format!( + "could not read POD source `{}`: {error}", + source_file.display() + ) + })?; + if !canonical_source.starts_with(&canonical_package) { + return Err("pod descriptor `source` must stay inside the native package".into()); + } + let source = fs::read_to_string(&canonical_source).map_err(|error| { + format!( + "could not read POD source `{}`: {error}", + canonical_source.display() + ) + })?; + let filename = canonical_source.to_string_lossy(); + let ast = perry_parser::parse_typescript(&source, &filename) + .map_err(|error| format!("could not parse POD source `{filename}`: {error}"))?; + perry_hir::set_current_module_source(source); + let lowered = perry_hir::lower_module(&ast, source_path, &filename); + perry_hir::clear_current_module_source(); + let hir = + lowered.map_err(|error| format!("could not lower POD source `{filename}`: {error}"))?; + perry_hir::exported_native_pod_abi(&hir, export_name) + .map_err(|error| format!("invalid POD source `{source_reference}`: {error}")) +} + +fn pod_contract_drift( + manifest: &[NativePodFieldAbi], + source: &[NativePodFieldAbi], + prefix: &str, +) -> Option { + if manifest.len() != source.len() { + return Some(format!( + "{prefix}field count is {} in the manifest but {} in source", + manifest.len(), + source.len() + )); + } + for (index, (manifest_field, source_field)) in manifest.iter().zip(source).enumerate() { + if manifest_field.name != source_field.name { + return Some(format!( + "{prefix}field {index} is `{}` in the manifest but `{}` in source (field order is ABI-significant)", + manifest_field.name, source_field.name + )); + } + match (&manifest_field.ty, &source_field.ty) { + (NativeAbiType::Pod(manifest_pod), NativeAbiType::Pod(source_pod)) => { + let nested_prefix = format!("{prefix}{}.", manifest_field.name); + if let Some(reason) = + pod_contract_drift(&manifest_pod.fields, &source_pod.fields, &nested_prefix) + { + return Some(reason); + } + } + (manifest_ty, source_ty) if manifest_ty != source_ty => { + return Some(format!( + "{prefix}field `{}` is `{manifest_ty}` in the manifest but `{source_ty}` in source", + manifest_field.name + )); + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod pod_source_contract_tests { + use super::*; + use serde_json::json; + + fn source_package(source: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("temporary package"); + let package_json = dir.path().join("package.json"); + fs::write(&package_json, "{}").expect("package.json"); + fs::write(dir.path().join("native.ts"), source).expect("native source"); + (dir, package_json) + } + + #[test] + fn source_reference_derives_the_exact_ordered_pod_contract() { + let (_dir, package_json) = source_package( + r#" +import type { pod, i8, u16, isize, f32 } from "perry/native"; +export type Packet = pod<{ tag: i8; length: u16; cursor: isize; weight: f32 }>; +"#, + ); + let descriptor = json!({ + "kind": "pod", + "source": "./native.ts#Packet" + }); + let NativeAbiType::Pod(pod) = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &descriptor, + ) + .expect("source contract") else { + panic!("expected POD descriptor") + }; + assert_eq!(pod.fields[0].ty, NativeAbiType::I8); + assert_eq!(pod.fields[1].ty, NativeAbiType::U16); + assert_eq!(pod.fields[2].ty, NativeAbiType::ISize); + assert_eq!(pod.fields[3].ty, NativeAbiType::F32); + } + + #[test] + fn duplicate_manifest_contract_reports_order_and_width_drift() { + let (_dir, package_json) = source_package( + r#" +import type { pod, u8, u16 } from "perry/native"; +export type Packet = pod<{ tag: u8; length: u16 }>; +"#, + ); + let wrong_order = json!({ + "kind": "pod", + "source": "./native.ts#Packet", + "fields": [ + { "name": "length", "type": "u16" }, + { "name": "tag", "type": "u8" } + ] + }); + let error = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &wrong_order, + ) + .unwrap_err() + .to_string(); + assert!(error.contains("field order is ABI-significant"), "{error}"); + + let wrong_width = json!({ + "kind": "pod", + "source": "./native.ts#Packet", + "fields": [ + { "name": "tag", "type": "u16" }, + { "name": "length", "type": "u16" } + ] + }); + let error = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &wrong_width, + ) + .unwrap_err() + .to_string(); + assert!( + error.contains("is `u16` in the manifest but `u8` in source"), + "{error}" + ); + } + + #[test] + fn source_reference_cannot_escape_the_package() { + let (_dir, package_json) = source_package( + r#"import type { pod, u8 } from "perry/native"; +export type Packet = pod<{ tag: u8 }>;"#, + ); + let descriptor = json!({ "kind": "pod", "source": "../native.ts#Packet" }); + let error = parse_native_pod_descriptor( + &package_json, + 0, + "consume_packet", + "params[0]", + &descriptor, + ) + .unwrap_err() + .to_string(); + assert!( + error.contains("must stay inside the native package"), + "{error}" + ); + } } fn invalid_native_abi_error( diff --git a/docs/api/manifest.schema.json b/docs/api/manifest.schema.json index 2db9df7492..1d8a093a1f 100644 --- a/docs/api/manifest.schema.json +++ b/docs/api/manifest.schema.json @@ -95,12 +95,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", @@ -125,12 +131,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", @@ -155,12 +167,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", @@ -359,7 +377,11 @@ "abiPodDescriptor": { "type": "object", "additionalProperties": false, - "required": ["kind", "fields"], + "required": ["kind"], + "anyOf": [ + { "required": ["fields"] }, + { "required": ["source"] } + ], "properties": { "kind": { "const": "pod" }, "name": { @@ -373,13 +395,23 @@ "minItems": 1, "items": { "$ref": "#/$defs/abiPodFieldDescriptor" }, "description": "Ordered C-layout fields. Field order is part of the ABI." + }, + "source": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+?(?:#[^#]+)?$", + "description": "Package-relative TypeScript source contract, normally ./path.ts#ExportedPod. If fields are also present Perry rejects any ABI drift." } } }, "abiPodAndCountDescriptor": { "type": "object", "additionalProperties": false, - "required": ["kind", "fields"], + "required": ["kind"], + "anyOf": [ + { "required": ["fields"] }, + { "required": ["source"] } + ], "properties": { "kind": { "const": "pod+count" }, "name": { @@ -393,6 +425,12 @@ "minItems": 1, "items": { "$ref": "#/$defs/abiPodFieldDescriptor" }, "description": "Ordered C-layout record fields. The JS argument supplies a packed record view and Perry lowers it to data pointer plus record count." + }, + "source": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+?(?:#[^#]+)?$", + "description": "Package-relative TypeScript source contract, normally ./path.ts#ExportedPod. If fields are also present Perry rejects any ABI drift." } } }, @@ -431,10 +469,16 @@ "abiPodFieldString": { "type": "string", "enum": [ + "i8", + "i16", "i32", "i64", + "u8", + "byte", + "u16", "u32", "u64", + "isize", "usize", "f32", "f64", @@ -452,10 +496,16 @@ "properties": { "kind": { "enum": [ + "i8", + "i16", "i32", "i64", + "u8", + "byte", + "u16", "u32", "u64", + "isize", "usize", "f32", "f64", @@ -483,12 +533,18 @@ "string", "bool", "boolean", + "i8", + "i16", "i32", "i64", "i64_str", + "u8", + "byte", + "u16", "u32", "u64", "usize", + "isize", "f32", "f64", "number", diff --git a/docs/src/language/native-values.md b/docs/src/language/native-values.md index a625af381a..67b46b71e8 100644 --- a/docs/src/language/native-values.md +++ b/docs/src/language/native-values.md @@ -56,8 +56,8 @@ arena.dispose(); ## Supported scalar layouts -The first public slice exposes the native representations the POD and native -ABI verifier already supports: +The public profile exposes the native representations the POD and native ABI +verifier supports: | Type | Native representation | |---|---| @@ -105,9 +105,10 @@ The scalar aliases establish representation inside a `pod` layout and at supported native ABI boundaries. A matching checked conversion may initialize a POD field from a dynamic value without forcing the whole record back to an ordinary object; the conversion guard runs before the value enters the native -record. They do not change the semantics of -standalone TypeScript arithmetic. Guaranteed native lanes across -general-purpose collections are a later part of the native value profile. +record. They do not change standalone TypeScript arithmetic: operators still +follow ordinary JavaScript number rules unless an explicit checked conversion +is used. The brand records intent for POD layout and native boundaries; it is +not a second runtime number object. ## POD records @@ -130,6 +131,30 @@ POD layout uses the target's native byte order. It does not define a portable serialization format; use `DataView` or another explicit encoder when stored or transmitted bytes require a specified endianness. +POD assignment has value semantics. `const copy = header` snapshots the +declared scalar fields into independent storage, so later property writes do +not alias the original. Passing a standalone POD to an ordinary function also +passes an independent value, even when the compiler would otherwise inline +that function. Nested object initializers are flattened recursively according +to the declared layout. `PodView` is different: it is an explicit view over +arena storage and aliases that storage by design. + +## Materialization and optimization guarantees + +The checked value and layout behavior above is stable language contract. The +compiler may keep a proven POD local or scalar in native storage, but that is +an optimization rather than an observable promise. Passing values through an +ordinary TypeScript function, array, object, or other managed API may +materialize JavaScript-compatible numbers or objects. Materialization must +preserve the checked value; in particular, no `i64`, `u64`, `isize`, or +`usize` conversion can silently introduce an imprecise JavaScript number. + +At a `perry.nativeLibrary` boundary, manifest descriptors restore the exact C +ABI width and signedness. A manifest POD may reference the exported TypeScript +`pod` declaration; compilation and `perry native validate` reject drift in +field type or order before generating a call. See [Native Library Manifest +v1](../native-libraries/manifest-v1.md#functions). + ## Arena ownership `NativeArena.alloc` owns a fixed native allocation. `view` creates a typed diff --git a/docs/src/native-libraries/manifest-v1.md b/docs/src/native-libraries/manifest-v1.md index f5e7a2bdf5..a32d0361cb 100644 --- a/docs/src/native-libraries/manifest-v1.md +++ b/docs/src/native-libraries/manifest-v1.md @@ -121,8 +121,9 @@ Existing string spellings remain valid. The canonical descriptor vocabulary is: ```text -jsvalue, string, json, bool, i32, i64, i64_str, u32, u64, usize, -f32, f64, number, ptr, buffer_len, buffer+len, handle, +jsvalue, string, json, bool, i8, i16, i32, i64, i64_str, +u8, byte, u16, u32, u64, isize, usize, f32, f64, number, +ptr, buffer_len, buffer+len, handle, promise, pod, void ``` @@ -153,8 +154,9 @@ Descriptors with metadata may also use object form: { "kind": "pod", "name": "Packet", + "source": "./src/native.ts#Packet", "fields": [ - { "name": "tag", "type": "u32" }, + { "name": "tag", "type": "u8" }, { "name": "count", "type": "usize" }, { "name": "weight", "abi": { "kind": "f32" } } ] @@ -183,16 +185,35 @@ C-layout storage and pass to native code as a pointer. The `fields` array is ordered, and field order is part of the ABI. Each field must have a non-empty `name` and exactly one of `type` or `abi`. +Instead of repeating the record, `source` may reference an exported +`pod` alias in the same package: + +```json +{ "kind": "pod", "source": "./src/native.ts#Packet" } +``` + +The path must be package-relative and cannot escape the package. Perry derives +the ordered fields from source during compilation and `perry native validate`. +When both `source` and `fields` are present, they must match recursively; +width, signedness, field order, and nested-record drift are reported before +native code is called. A source without `#ExportName` uses the descriptor's +`name`. The referenced declaration must be an exported, non-generic `pod` +alias whose record is closed and contains no optional, managed, or pointerful +fields. + POD field types are restricted to numeric ABI scalars that have stable C layout: ```text -i32, i64, u32, u64, usize, f32, f64, number, buffer_len +i8, i16, i32, i64, u8, byte, u16, u32, u64, isize, usize, +f32, f64, number, buffer_len, handle_id, nested pod ``` -`number` aliases `f64`; `buffer_len` is a `u32` byte-length scalar. +`byte` aliases `u8`, `number` aliases `f64`, and `buffer_len` is a `u32` +byte-length scalar. `handle_id` is a pointer-free integer identifier; it is +not an owned or borrowed `handle`. Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, -`bool`, `ptr`, `buffer+len`, `handle`, `promise`, nested `pod`, and +`bool`, `ptr`, `buffer+len`, `handle`, `promise`, and `void` are rejected in POD fields. ### Param types @@ -203,11 +224,16 @@ Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, | `"string"` | `*const StringHeader` | `string` | | `"json"` | `*const StringHeader` | any JSON-serializable value (`JSON.stringify`d at the callsite) | | `"bool"` | `i32` truthy flag | `boolean` | -| `"i32"` | `i32` | `number` truncated to signed 32-bit | -| `"i64"` | `i64` | `number` converted to signed 64-bit | -| `"u32"` | `u32` | `number` converted to unsigned 32-bit | -| `"u64"` | `u64` | `number` converted to unsigned 64-bit | -| `"usize"` | `usize` | `number` converted to pointer-sized unsigned integer | +| `"i8"` | `i8` | checked signed 8-bit `number` | +| `"i16"` | `i16` | checked signed 16-bit `number` | +| `"i32"` | `i32` | checked signed 32-bit `number` | +| `"i64"` | `i64` | checked safe-integer `number` | +| `"u8"` / `"byte"` | `u8` | checked unsigned 8-bit `number` | +| `"u16"` | `u16` | checked unsigned 16-bit `number` | +| `"u32"` | `u32` | checked unsigned 32-bit `number` | +| `"u64"` | `u64` | checked non-negative safe-integer `number` | +| `"isize"` | `isize` | checked pointer-sized signed safe-integer `number` | +| `"usize"` | `usize` | checked pointer-sized unsigned safe-integer `number` | | `"f32"` | `f32` | `number` narrowed to 32-bit float | | `"f64"` / `"number"` | `f64` | `number` | | `"ptr"` | `i64` raw boxed pointer payload | raw pointer escape hatch | @@ -226,10 +252,15 @@ Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, | `"ptr"` | `-> *const u8` *(see note)* | `string` legacy pointer return | | `"i64_str"` | `-> i64` | `string` (the `i64` is a `*StringHeader`) | | `"bool"` | `-> i32` | `boolean` | +| `"i8"` | `-> i8` | `number` | +| `"i16"` | `-> i16` | `number` | | `"i32"` | `-> i32` | `number` | | `"i64"` | `-> i64` | `number` | +| `"u8"` / `"byte"` | `-> u8` | `number` | +| `"u16"` | `-> u16` | `number` | | `"u32"` | `-> u32` | `number` | | `"u64"` | `-> u64` | `number` | +| `"isize"` | `-> isize` | `number` | | `"usize"` | `-> usize` | `number` | | `"f32"` | `-> f32` | `number` via explicit `f32 -> f64` materialization | | `"f64"` / `"number"` | `-> f64` | `number` | @@ -238,6 +269,10 @@ Dynamic or pointerful descriptors such as `jsvalue`, `string`, `json`, | `"promise"` / `"promise"` | `-> i64` | JavaScript `Promise` | | `"void"` | `-> ()` | `undefined` | +Signed and unsigned 64-bit returns (including `isize`/`usize`) are checked +before becoming a TypeScript `number`. A value outside JavaScript's exact +safe-integer range throws a `RangeError`; Perry never silently rounds it. + > Note on `"string"` vs. `"i64_str"`: both produce a string on the > TypeScript side, but they differ in how Rust returns the pointer. > Use `"string"` / `"ptr"` when your `extern "C" fn` is declared @@ -260,7 +295,8 @@ verifier-backed C-layout storage. > object. It is opt-in and param-only, so real-string `"string"` params > keep their strict non-string-rejecting check. -Native-only numeric descriptors (`f32`, `u32`, `u64`, `usize`, +Native-only numeric descriptors (`i8` through `i64`, `u8` through `u64`, +`isize`, `usize`, `f32`, `buffer_len`) render as TypeScript `number`. Handles remain opaque GC-managed values, even though native functions still receive and return raw `i64` resource pointers at the ABI boundary. POD parameters diff --git a/scripts/ci_e2e_scope.py b/scripts/ci_e2e_scope.py index 94e88edd69..a364c98be3 100755 --- a/scripts/ci_e2e_scope.py +++ b/scripts/ci_e2e_scope.py @@ -121,6 +121,7 @@ "constructor_recursion", "destructure_call_location", "i64_spec_ternary_recursion", + "ios_platform_api_lowering", "large_object_barriers", "loop_safepoint_purity", "macos_bundle_chdir_gate", @@ -143,6 +144,7 @@ "spec_abi_typed_array_local_length", "static_symbol_hygiene", "temp_root_operand_temporaries", + "typed_array_rmw_8692", "typed_shape_declared_at_allocation", "typed_shape_descriptor", "typed_shape_descriptors", diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index f8af29e696..986057a159 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -5,13 +5,13 @@ "crates/perry-ext-commander/src/lib.rs": 4, "crates/perry-ext-cron/src/lib.rs": 2, "crates/perry-ext-decimal/src/lib.rs": 1, - "crates/perry-ext-events/src/lib.rs": 23, + "crates/perry-ext-events/src/lib.rs": 21, "crates/perry-ext-events/src/module_iterators.rs": 2, "crates/perry-ext-events/src/tests.rs": 2, "crates/perry-ext-fastify/src/upgrade.rs": 4, "crates/perry-ext-fetch/src/lib.rs": 14, "crates/perry-ext-fetch/src/tests.rs": 14, - "crates/perry-ext-http/src/agent.rs": 4, + "crates/perry-ext-http/src/agent.rs": 3, "crates/perry-ext-http/src/client_request_surface.rs": 2, "crates/perry-ext-http/src/response_headers.rs": 1, "crates/perry-ext-http/src/server/handle_dispatch.rs": 2, @@ -35,15 +35,14 @@ "crates/perry-stdlib/src/cron.rs": 2, "crates/perry-stdlib/src/crypto/kdf.rs": 9, "crates/perry-stdlib/src/crypto/sign.rs": 22, - "crates/perry-stdlib/src/crypto/util.rs": 3, - "crates/perry-stdlib/src/crypto/x509.rs": 19, + "crates/perry-stdlib/src/crypto/util.rs": 2, "crates/perry-stdlib/src/domain.rs": 3, "crates/perry-stdlib/src/ethers.rs": 5, "crates/perry-stdlib/src/events.rs": 6, "crates/perry-stdlib/src/events/constructors.rs": 1, "crates/perry-stdlib/src/events/events_on.rs": 17, "crates/perry-stdlib/src/events/module_helpers.rs": 1, - "crates/perry-stdlib/src/events/once_helpers.rs": 4, + "crates/perry-stdlib/src/events/once_helpers.rs": 1, "crates/perry-stdlib/src/events/warnings.rs": 1, "crates/perry-stdlib/src/fetch/mod.rs": 6, "crates/perry-stdlib/src/ioredis.rs": 14, @@ -52,7 +51,6 @@ "crates/perry-stdlib/src/mysql2/pool.rs": 2, "crates/perry-stdlib/src/mysql2/result.rs": 43, "crates/perry-stdlib/src/mysql2/types.rs": 16, - "crates/perry-stdlib/src/net/mod.rs": 3, "crates/perry-stdlib/src/nodemailer.rs": 3, "crates/perry-stdlib/src/pg/result.rs": 14, "crates/perry-stdlib/src/pg/types.rs": 14, @@ -86,5 +84,5 @@ "crates/perry-stdlib/src/zlib.rs": 2 }, "schema_version": 2, - "total": 605 + "total": 576 } diff --git a/test-files/test_method_guard_name_invalidation.ts b/test-files/test_method_guard_name_invalidation.ts new file mode 100644 index 0000000000..93564f9b4c --- /dev/null +++ b/test-files/test_method_guard_name_invalidation.ts @@ -0,0 +1,67 @@ +// Direct-method guards are invalidated by method name. Unrelated prototype +// writes must leave a hot guard usable, while writes for the guarded name must +// fall back and observe the replacement across an inheritance chain. + +class MethodGuardBase { + value: number; + + constructor(value: number) { + this.value = value; + } + + hot(): string { + return "base:" + this.value; + } +} + +class MethodGuardChild extends MethodGuardBase {} + +class MethodGuardOther { + cold(): string { + return "cold"; + } +} + +function callHot(receiver: MethodGuardBase): string { + return receiver.hot(); +} + +const receiver: MethodGuardBase = new MethodGuardChild(7); +console.log(callHot(receiver)); + +class MethodGuardDelete { + gone(): string { + return "present"; + } +} + +function callGone(receiver: MethodGuardDelete): string { + return receiver.gone(); +} + +const deleted = new MethodGuardDelete(); +console.log(callGone(deleted)); +delete (MethodGuardDelete.prototype as any).gone; +try { + console.log(callGone(deleted)); +} catch (_error) { + console.log("deleted"); +} + +(MethodGuardOther.prototype as any).cold = function (): string { + return "patched-cold"; +}; +console.log(callHot(receiver)); + +// A same-name write on any class conservatively retires the hash slot. It +// must not change this receiver's answer, but subsequent direct guards may no +// longer assume that `hot` is untouched. +(MethodGuardOther.prototype as any).hot = function (): string { + return "other-hot"; +}; +console.log(callHot(receiver)); + +(MethodGuardBase.prototype as any).hot = function (this: MethodGuardBase): string { + return "patched:" + this.value; +}; +console.log(callHot(receiver)); diff --git a/test-files/test_parity_native_value_profile.ts b/test-files/test_parity_native_value_profile.ts index 476e135fb9..5863bda19a 100644 --- a/test-files/test_parity_native_value_profile.ts +++ b/test-files/test_parity_native_value_profile.ts @@ -16,6 +16,11 @@ type Narrow = NativeRecord<{ pointerDelta: SignedSize; }>; +type Nested = NativeRecord<{ + outer: Octet; + inner: NativeRecord<{ code: HalfWord; delta: SignedByte }>; +}>; + const size = sizeOf
(); const alignment = alignOf
(); const sequenceOffset = offsetOf
("sequence"); @@ -48,6 +53,16 @@ const convertedHeader: Header = { sequence: LongWord(42), gain: FloatWord(0.1), }; +const originalNested: Nested = { + outer: Octet(7), + inner: { code: HalfWord(513), delta: SignedByte(-8) }, +}; +let copiedNested = originalNested; +copiedNested.outer = Octet(9); +function mutateHeader(value: Header): void { + value.flags = Word(99); +} +mutateHeader(convertedHeader); let rejectedFraction = false; let rejectedType = false; let rejectedOctet = false; @@ -125,6 +140,8 @@ console.log( ":" + convertedSignedHalfWord + ":" + convertedSignedSize + ":" + narrow.delta + ":" + narrow.count + ":" + narrow.offset + ":" + narrow.pointerDelta + ",header=" + convertedHeader.flags + ":" + convertedHeader.sequence + ":" + (convertedHeader.gain > 0.1) + + ",podCopy=" + originalNested.outer + ":" + copiedNested.outer + + ":" + originalNested.inner.code + ":" + copiedNested.inner.delta + ",rejectedFraction=" + rejectedFraction + ",rejectedType=" + rejectedType + ",rejectedOctet=" + rejectedOctet +