diff --git a/Cargo.lock b/Cargo.lock index 2a9e494b60..6389c5f135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6180,6 +6180,7 @@ dependencies = [ "image", "kamadak-exif", "perry-ffi", + "perry-runtime", ] [[package]] diff --git a/benchmarks/compiler_output/workloads.toml b/benchmarks/compiler_output/workloads.toml index 93511805da..c54637ef87 100644 --- a/benchmarks/compiler_output/workloads.toml +++ b/benchmarks/compiler_output/workloads.toml @@ -2134,3 +2134,62 @@ name = "imported_registry_generic_fallback_retained" consumer = "proven_this_method_direct_call" notes_contains = "generic_dispatch_fallback=js_native_call_method_by_id" min = 2 + +[workloads.issue_8775_imported_object] +source = "test-files/fixtures/issue_8775_imported_object/main.js" +kind = "imported_object_literal_method_specialization" +allow_hot_loop_conversions = true +allow_dynamic_property_runtime = true + +[workloads.issue_8775_imported_object.vectorization] +min_vectorized_loops = 0 +scalar_baseline = "allowed: this fixture gates cross-module own-method dispatch" +allowed_missed_reason_kinds = [ + "call_instruction", + "control_flow", + "generic_not_vectorized", + "not_beneficial", + "uncountable_loop", + "unknown_trip_count", + "unsupported_instruction", + "unsupported_reduction", +] + +[workloads.issue_8775_imported_object.runtime_budgets] + +[[workloads.issue_8775_imported_object.stdout_checks]] +name = "imported_object_checksum" +equals = "{\"checksum\":400000,\"remaining\":0}\n" +detail = "stable imported object methods preserve the Node checksum" + +[[workloads.issue_8775_imported_object.ir_checks]] +name = "direct_producer_closure_calls" +section = "llvm_before" +contains_all = [ + "call double @perry_closure_adapter_js__6", + "call double @perry_closure_adapter_js__7", + "call double @perry_closure_adapter_js__8", + "call double @perry_closure_adapter_js__9", +] +detail = "stable arms directly call producer closure bodies" + +[[workloads.issue_8775_imported_object.ir_checks]] +name = "generic_method_fallback_retained" +section = "llvm_before" +contains = "call double @js_native_call_method_by_id" +detail = "guard failures retain the universal method dispatcher" + +[workloads.issue_8775_imported_object.native_rep_checks] +allow_materialization_reasons = ["runtime_api"] + +[[workloads.issue_8775_imported_object.native_rep_checks.require_records]] +name = "imported_object_direct_selection" +consumer = "imported_object_literal_method_direct_call" +notes_contains = "receiver_provenance=imported_object_literal_metadata" +min = 5 + +[[workloads.issue_8775_imported_object.native_rep_checks.require_records]] +name = "imported_object_generic_fallback_retained" +consumer = "imported_object_literal_method_direct_call" +notes_contains = "generic_dispatch_fallback=js_native_call_method_by_id" +min = 5 diff --git a/changelog.d/8750-ext-error-objects.md b/changelog.d/8750-ext-error-objects.md new file mode 100644 index 0000000000..01683f96f3 --- /dev/null +++ b/changelog.d/8750-ext-error-objects.md @@ -0,0 +1 @@ +Reject native-extension failures with real JavaScript `Error` objects, including `.message`/`.stack`, and preserve mysql2-compatible `.code`/`.errno` metadata for common MySQL server errors. diff --git a/changelog.d/8785-imported-object-method-specialization.md b/changelog.d/8785-imported-object-method-specialization.md new file mode 100644 index 0000000000..2461ae5f9f --- /dev/null +++ b/changelog.d/8785-imported-object-method-specialization.md @@ -0,0 +1 @@ +Stable imported object-literal methods now use guarded direct calls to their defining closure bodies. The fast path validates the exported receiver identity, exact own-property shape, and live function identity, while replacements, deletions, accessors, proxies, receiver changes, and other dynamic cases retain the universal method-dispatch fallback. diff --git a/changelog.d/8786-closure-captured-packed-loops.md b/changelog.d/8786-closure-captured-packed-loops.md new file mode 100644 index 0000000000..efd86774a3 --- /dev/null +++ b/changelog.d/8786-closure-captured-packed-loops.md @@ -0,0 +1 @@ +Optimize immutable closure-captured packed Array and Array-subclass loops, including nested arrays derived from guarded indexed reads, while retaining generic side exits for rebinding, layout changes, and moving GC. diff --git a/changelog.d/8788-short-packed-spread.md b/changelog.d/8788-short-packed-spread.md new file mode 100644 index 0000000000..ac8ee89c84 --- /dev/null +++ b/changelog.d/8788-short-packed-spread.md @@ -0,0 +1,3 @@ +## perf(codegen): direct-call stable methods with short packed spread tails + +`receiver.method(fixed, ...args)` now emits guarded direct-call arms when the final spread is an exact ordinary packed Array with zero through four present elements. The guard rejects holes, iterator/prototype overrides, proxies, Array subclasses, descriptors, and oversized tails; method class/shape/invalidation guards select the concrete body, and every miss retains the full iterator-aware apply dispatcher. This removes argument-array materialization and dynamic method lookup from the common empty/one-element ECS dispatch path while preserving source-order evaluation and moving-GC roots. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 6c5b4ca67c..d83364e5da 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1116,7 +1116,9 @@ pub(super) fn compile_closure( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 7fc4ac852c..c085123359 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -914,7 +914,9 @@ pub(super) fn compile_module_entry( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: main_native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, @@ -1619,7 +1621,9 @@ pub(super) fn compile_module_entry( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: init_native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 0e59ec451f..04459d32f0 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1166,7 +1166,9 @@ pub(super) fn compile_function( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index de5993cd4a..8d19db752f 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -498,7 +498,9 @@ pub(super) fn compile_method( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, @@ -1770,7 +1772,9 @@ pub(super) fn compile_static_method( local_class_field_aliases: HashMap::new(), local_id_to_name: HashMap::new(), local_value_aliases: HashMap::new(), + local_imported_object_aliases: HashMap::new(), imported_vars: &cross_module.imported_vars, + imported_object_literals: &cross_module.imported_object_literals, compile_time_constants: native_facts.compile_time_constants(), target_triple: &cross_module.target_triple, app_metadata: &cross_module.app_metadata, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 2f480ac6b0..f65401464a 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -240,7 +240,8 @@ pub(crate) use helpers::{ module_callable_count, set_full_outline_ic, write_barriers_enabled, }; pub use opts::{ - AppMetadata, CompileOptions, FpContractMode, ImportedClass, NamespaceEntry, NamespaceEntryKind, + AppMetadata, CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, + ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, }; pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; pub(crate) use param_guard::scalar_descriptor_rep; @@ -2219,6 +2220,28 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> }) .collect(); + let imported_object_literals: std::collections::HashMap = opts + .imported_classes + .iter() + .filter_map(|imported| { + imported + .object_literal + .as_ref() + .map(|object| (object.local_binding.clone(), object.clone())) + }) + .collect(); + let imported_object_producers: std::collections::BTreeSet<(String, u32)> = + imported_object_literals + .values() + .map(|object| (object.source_prefix.clone(), object.source_global_id)) + .collect(); + for (source_prefix, source_global_id) in imported_object_producers { + llmod.add_external_global( + &format!("perry_global_{source_prefix}__{source_global_id}"), + DOUBLE, + ); + } + let mut cross_module = CrossModuleCtx { namespace_imports: opts.namespace_imports.iter().cloned().collect(), namespace_member_nested: opts.namespace_member_nested.iter().cloned().collect(), @@ -2295,6 +2318,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } }), imported_vars: opts.imported_vars, + imported_object_literals, needs_stdlib: opts.needs_stdlib, needs_geisterhand: opts.needs_geisterhand, geisterhand_port: opts.geisterhand_port, diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 97d21fe568..cc33764172 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -607,6 +607,48 @@ pub struct ImportedClass { /// facts. Kept beside the class metadata so the proof and the field layout /// it names enter the consumer atomically and share one object-cache key. pub return_shape_imports: Vec, + /// Producer-authored capability for an imported, immutable object-literal + /// binding. Such entries also carry the anonymous shape class above so the + /// consumer can validate its source class/ShapeId pair, but they are not + /// JavaScript class imports and are only consumed by the guarded own-method + /// lowering. + pub object_literal: Option, +} + +/// One concise own method published by an exported object-literal capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedObjectLiteralMethod { + pub name: String, + pub func_id: u32, + pub param_count: usize, + pub field_index: u32, +} + +/// Consumer-resolved capability for one imported object-literal binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedObjectLiteral { + /// The identifier carried by `Expr::ExternFuncRef` in the consumer. + pub local_binding: String, + /// Public export name at the defining module, retained for diagnostics. + pub source_export_name: String, + pub source_prefix: String, + /// Consumer-local name of the imported anonymous shape stub. + pub receiver_class_name: String, + /// LocalId of the defining module's immutable global binding. + pub source_global_id: u32, + pub methods: Vec, +} + +/// Defining-module fact harvested before parallel code generation. Absence is +/// authoritative: importers never infer an object capability from a getter or +/// from their own call-site observations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportedObjectLiteralCapability { + pub class_name: String, + pub class_id: u32, + pub global_id: u32, + pub field_names: Vec, + pub methods: Vec, } /// Constructor metadata for a class imported from another module. @@ -793,6 +835,9 @@ pub(crate) struct CrossModuleCtx { pub i18n: Option, /// Names of imports that are exported variables (not functions). pub imported_vars: std::collections::HashSet, + /// Producer-authored immutable imported object-literal capabilities, + /// keyed by the consumer's local import binding. + pub imported_object_literals: std::collections::HashMap, /// Whether perry-stdlib will be linked into the final binary. When /// false, compile_module_entry skips the `js_stdlib_init_dispatch()` /// call in main's prologue because only the runtime is linked and diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 09add949a7..2e1b86a636 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -30,6 +30,7 @@ mod loop_bounded_i32; mod mutation; mod not_bigint_locals; mod number_by_construction; +mod object_literal_exports; mod param_ranges; mod pointer_locals; mod proven_args; @@ -85,6 +86,7 @@ pub(crate) use integer_locals::{ pub(crate) use local_refs::{expr_contains_local_get, mark_all_candidate_refs_in_expr}; pub(crate) use mutation::{body_contains_call, body_contains_closure, has_any_mutation}; pub(crate) use number_by_construction::collect_number_by_construction_locals; +pub(crate) use object_literal_exports::exported_object_literal_capabilities; pub(crate) use param_ranges::{collect_param_int_ranges, ParamIntRanges}; pub(crate) use pointer_locals::collect_pointer_typed_locals; pub(crate) use proven_args::{ diff --git a/crates/perry-codegen/src/collectors/object_literal_exports.rs b/crates/perry-codegen/src/collectors/object_literal_exports.rs new file mode 100644 index 0000000000..20f551e549 --- /dev/null +++ b/crates/perry-codegen/src/collectors/object_literal_exports.rs @@ -0,0 +1,205 @@ +//! Producer-side capabilities for stable exported object literals. +//! +//! The consumer cannot inspect another module's initializer or closure bodies, +//! so this collector is the sole authority for the guarded direct-call route. +//! It recognizes the source-ordered object-building IIFE emitted by HIR only +//! when that IIFE starts from a non-zero anonymous shape and finishes with an +//! own concise method in the corresponding inline field. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Export, Expr, Module, Stmt}; + +use crate::codegen::{ExportedObjectLiteralCapability, ImportedObjectLiteralMethod}; + +fn local_get_is(expr: &Expr, expected: u32) -> bool { + matches!(expr, Expr::LocalGet(id) if *id == expected) +} + +fn capability_from_init( + hir: &Module, + global_id: u32, + init: &Expr, +) -> Option { + let Expr::Call { callee, args, .. } = init else { + return None; + }; + let Expr::Closure { + params, + body, + is_async: false, + is_generator: false, + .. + } = callee.as_ref() + else { + return None; + }; + let [param] = params.as_slice() else { + return None; + }; + if param.name != "__perry_obj_iife" { + return None; + } + let [Expr::New { + class_name, + args: seed_args, + .. + }] = args.as_slice() + else { + return None; + }; + let class = hir.classes.iter().find(|class| { + class.name == *class_name + && class.id != 0 + && class.fields.iter().all(|field| field.key_expr.is_none()) + })?; + if class.fields.len() != seed_args.len() + || !seed_args.iter().all(|arg| matches!(arg, Expr::Undefined)) + { + return None; + } + + // Last source write wins. A later data/function-valued write to the same + // key deliberately erases an earlier concise-method capability. + let mut final_methods: HashMap> = HashMap::new(); + let mut saw_return = false; + for stmt in body { + match stmt { + Stmt::Expr(Expr::IndexSet { object, index, .. }) if local_get_is(object, param.id) => { + let Expr::String(key) = index.as_ref() else { + return None; + }; + final_methods.insert(key.clone(), None); + } + Stmt::Expr(Expr::Call { callee, args, .. }) => { + let Expr::ExternFuncRef { name, .. } = callee.as_ref() else { + return None; + }; + if name != "js_object_set_method_by_name" { + return None; + } + let [receiver, Expr::String(key), value] = args.as_slice() else { + return None; + }; + if !local_get_is(receiver, param.id) { + return None; + } + let Expr::Closure { + func_id, + params, + captures_this: true, + is_arrow: false, + is_async: false, + is_generator: false, + .. + } = value + else { + final_methods.insert(key.clone(), None); + continue; + }; + // The first slice uses the existing exact-arity closure guard. + // Rest and synthesized `arguments` slots remain generic. + if params + .iter() + .any(|param| param.is_rest || param.arguments_object.is_some()) + { + final_methods.insert(key.clone(), None); + continue; + } + let field_index = class.fields.iter().position(|field| field.name == *key)? as u32; + final_methods.insert( + key.clone(), + Some(ImportedObjectLiteralMethod { + name: key.clone(), + func_id: *func_id, + param_count: params.len(), + field_index, + }), + ); + } + Stmt::Return(Some(value)) if local_get_is(value, param.id) && !saw_return => { + saw_return = true; + } + _ => return None, + } + } + if !saw_return { + return None; + } + + let field_names: Vec = class + .fields + .iter() + .map(|field| field.name.clone()) + .collect(); + let field_set: HashSet<&str> = field_names.iter().map(String::as_str).collect(); + if final_methods + .keys() + .any(|key| !field_set.contains(key.as_str())) + { + return None; + } + let mut methods: Vec = + final_methods.into_values().flatten().collect(); + methods.sort_by_key(|method| method.field_index); + if methods.is_empty() { + return None; + } + + Some(ExportedObjectLiteralCapability { + class_name: class.name.clone(), + class_id: class.id, + global_id, + field_names, + methods, + }) +} + +pub(crate) fn exported_object_literal_capabilities( + hir: &Module, +) -> HashMap { + let exported_objects: HashSet<&str> = hir.exported_objects.iter().map(String::as_str).collect(); + let mut exported_locals: HashSet<&str> = exported_objects.clone(); + for export in &hir.exports { + if let Export::Named { local, exported } = export { + if exported_objects.contains(exported.as_str()) { + exported_locals.insert(local.as_str()); + } + } + } + let mut by_local = HashMap::new(); + for stmt in crate::codegen::entry_outline::logical_entry_stmts(hir) { + let Stmt::Let { + id, + name, + mutable: false, + init: Some(init), + .. + } = stmt + else { + continue; + }; + if !exported_locals.contains(name.as_str()) { + continue; + } + if let Some(capability) = capability_from_init(hir, *id, init) { + by_local.insert(name.clone(), capability); + } + } + + let mut published = HashMap::new(); + for (local, capability) in &by_local { + // `export default { ... }` and direct named exports both use their + // public name in `exported_objects`; retain that fail-safe route even + // if an older HIR producer omitted a redundant `Export::Named` row. + published.insert(local.clone(), capability.clone()); + } + for export in &hir.exports { + if let Export::Named { local, exported } = export { + if let Some(capability) = by_local.get(local) { + published.insert(exported.clone(), capability.clone()); + } + } + } + published +} diff --git a/crates/perry-codegen/src/expr/call_spread.rs b/crates/perry-codegen/src/expr/call_spread.rs index 46e10c4588..83ae3ac027 100644 --- a/crates/perry-codegen/src/expr/call_spread.rs +++ b/crates/perry-codegen/src/expr/call_spread.rs @@ -330,6 +330,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } if !skip { + if let Some(result) = + super::call_spread_short::try_lower(ctx, object, property, args)? + { + return Ok(result); + } let recv_box = lower_expr(ctx, object)?; // Build a single JS array containing every arg in order. // diff --git a/crates/perry-codegen/src/expr/call_spread_short.rs b/crates/perry-codegen/src/expr/call_spread_short.rs new file mode 100644 index 0000000000..6e04e54a5b --- /dev/null +++ b/crates/perry-codegen/src/expr/call_spread_short.rs @@ -0,0 +1,352 @@ +//! Guarded direct calls for `receiver.method(fixed..., ...shortArray)` (#8772). +//! +//! The spread expression is still evaluated exactly once and in source order. +//! A non-allocating runtime proof then admits only an exact ordinary packed +//! Array with 0..=4 present elements. The method side is independently guarded +//! by the same `(class id, ShapeId, method invalidation slot)` proof used by +//! ordinary shape-directed method calls. Either miss joins the existing apply +//! path, which drives the full iterator protocol. + +use anyhow::Result; +use perry_hir::{CallArg, Expr}; + +use crate::nanbox::double_literal; +use crate::native_value::LoweredValue; +use crate::types::{DOUBLE, I1, I32, I64, PTR}; + +use super::FnCtx; + +const MAX_SPREAD_ARITY: usize = 4; +const MAX_METHOD_ARMS: usize = 8; + +#[derive(Clone)] +struct DirectCandidate { + class_id: u32, + class_name: String, + target: String, + declared_count: usize, +} + +/// Collect concrete class implementations in deterministic class-id order. +/// +/// Rest/`arguments` bodies are intentionally left to the generic dispatcher: +/// their direct ABI allocates one or two argument arrays and would erase the +/// small-tail win. An omitted class is harmless because a guard miss always +/// reaches apply. +fn direct_candidates(ctx: &FnCtx<'_>, property: &str) -> Vec { + let mut roots: Vec<(&String, u32)> = + ctx.class_ids.iter().map(|(name, &id)| (name, id)).collect(); + roots.sort_unstable_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0))); + + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for (class_name, class_id) in roots { + let Some(_keys_global) = ctx.class_keys_globals.get(class_name) else { + continue; + }; + let mut current = Some(class_name.clone()); + while let Some(owner) = current { + let key = (owner.clone(), property.to_string()); + if let Some(public_target) = ctx.methods.get(&key) { + let unsupported_abi = public_target.starts_with("perry_static_") + || matches!(ctx.method_has_rest.get(&key), Some(true)) + || matches!(ctx.method_has_synthetic_arguments.get(&key), Some(true)); + if !unsupported_abi && seen.insert((class_id, public_target.clone())) { + let target = if owner == *class_name + && ctx + .pshape_methods + .contains_key(&(owner.clone(), property.to_string())) + { + crate::collectors::pshape_method_name(public_target) + } else { + public_target.clone() + }; + out.push(DirectCandidate { + class_id, + class_name: class_name.clone(), + target, + declared_count: ctx.method_param_counts.get(&key).copied().unwrap_or(0), + }); + if out.len() == MAX_METHOD_ARMS { + return out; + } + } + break; + } + current = ctx + .classes + .get(&owner) + .and_then(|class| class.extends_name.clone()); + } + } + out +} + +fn first_element_ptr(ctx: &mut FnCtx<'_>, alloca: &str, count: usize) -> String { + let ptr = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{ptr} = getelementptr [{count} x double], ptr {alloca}, i64 0, i64 0" + )); + ptr +} + +/// Try the #8772 lowering. `None` means the caller must retain its existing +/// source-ordered argument bundling path. +pub(crate) fn try_lower<'f, 'e>( + ctx: &mut FnCtx<'f>, + object: &'e Expr, + property: &str, + args: &'e [CallArg], +) -> Result> { + let Some(CallArg::Spread(spread_expr)) = args.last() else { + return Ok(None); + }; + if args[..args.len() - 1] + .iter() + .any(|arg| !matches!(arg, CallArg::Expr(_))) + { + return Ok(None); + } + let candidates = direct_candidates(ctx, property); + if candidates.is_empty() { + return Ok(None); + } + + // Receiver, fixed arguments, then the final spread expression: exactly the + // ECMAScript evaluation order and exactly once each. One open group keeps + // every pointer-bearing value current through both CFG diamonds and the + // allocating generic fallback. + let mut roots = crate::rooting::open_rooted_group(args.len() + 1); + let recv_root = roots.lower(ctx, object, true)?; + let mut fixed_roots = Vec::with_capacity(args.len().saturating_sub(1)); + for arg in &args[..args.len() - 1] { + let CallArg::Expr(expr) = arg else { + unreachable!() + }; + fixed_roots.push(roots.lower(ctx, expr, true)?); + } + let spread_root = roots.lower(ctx, spread_expr, true)?; + + // Re-read once below all operand evaluation. The two guards from here to a + // direct call are non-allocating; fallback re-reads again after its + // materializer allocates. + let fast_recv = roots.reread(ctx, recv_root)?; + let fast_fixed: Vec = fixed_roots + .iter() + .map(|&root| roots.reread(ctx, root)) + .collect::>()?; + let fast_spread = roots.reread(ctx, spread_root)?; + + let values_alloca = ctx.func.alloca_entry_array(DOUBLE, MAX_SPREAD_ARITY); + let values_ptr = first_element_ptr(ctx, &values_alloca, MAX_SPREAD_ARITY); + let arity = ctx.block().call( + I32, + "js_short_packed_spread_values", + &[(DOUBLE, &fast_spread), (PTR, &values_ptr)], + ); + + let method_probe_idx = ctx.new_block("short_spread.method_probe"); + let fallback_idx = ctx.new_block("short_spread.fallback"); + let merge_idx = ctx.new_block("short_spread.merge"); + let method_probe_label = ctx.block_label(method_probe_idx); + let fallback_label = ctx.block_label(fallback_idx); + let merge_label = ctx.block_label(merge_idx); + let packed = ctx.block().icmp_sge(I32, &arity, "0"); + ctx.block() + .cond_br(&packed, &method_probe_label, &fallback_label); + + // Load compiler-published ShapeIds once. Entry-init slots dominate this + // whole diamond; these ordinary loads do not allocate. + ctx.current_block = method_probe_idx; + let expected_shapes: Vec = candidates + .iter() + .map(|candidate| { + let keys = ctx + .class_keys_globals + .get(&candidate.class_name) + .expect("candidate required a keys global") + .clone(); + crate::typed_shape::load_class_shape_id(ctx, &candidate.class_name, &keys) + }) + .collect(); + let key_idx = ctx.strings.intern(property); + let entry = ctx.strings.entry(key_idx); + let method_guard_slot = (entry.dispatch_hash & 0xffff).to_string(); + let shape_out = ctx.func.alloca_entry(I32); + let live_class = ctx.block().call( + I32, + "js_method_direct_shape_class", + &[ + (DOUBLE, &fast_recv), + (PTR, &shape_out), + (I32, &method_guard_slot), + ], + ); + let live_shape = ctx.block().load(I32, &shape_out); + + let candidate_test_idxs: Vec = (0..candidates.len()) + .map(|index| ctx.new_block(&format!("short_spread.target_test{index}"))) + .collect(); + let candidate_select_idxs: Vec = (0..candidates.len()) + .map(|index| ctx.new_block(&format!("short_spread.target{index}"))) + .collect(); + let first_candidate_label = ctx.block_label(candidate_test_idxs[0]); + ctx.block().br(&first_candidate_label); + + let mut phi_inputs: Vec<(String, String)> = Vec::new(); + let undefined = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + for (candidate_no, candidate) in candidates.iter().enumerate() { + ctx.current_block = candidate_test_idxs[candidate_no]; + let target_label = ctx.block_label(candidate_select_idxs[candidate_no]); + let miss_label = candidate_test_idxs + .get(candidate_no + 1) + .map(|&index| ctx.block_label(index)) + .unwrap_or_else(|| fallback_label.clone()); + let cid_ok = ctx + .block() + .icmp_eq(I32, &live_class, &candidate.class_id.to_string()); + let shape_ok = ctx + .block() + .icmp_eq(I32, &live_shape, &expected_shapes[candidate_no]); + let target_ok = ctx.block().and(I1, &cid_ok, &shape_ok); + ctx.block().cond_br(&target_ok, &target_label, &miss_label); + + ctx.current_block = candidate_select_idxs[candidate_no]; + let arity_blocks: Vec = (0..=MAX_SPREAD_ARITY) + .map(|spread_arity| { + ctx.new_block(&format!( + "short_spread.target{candidate_no}.arity{spread_arity}" + )) + }) + .collect(); + let arity_tests: Vec = (1..=MAX_SPREAD_ARITY) + .map(|spread_arity| { + ctx.new_block(&format!( + "short_spread.target{candidate_no}.arity_test{spread_arity}" + )) + }) + .collect(); + for spread_arity in 0..=MAX_SPREAD_ARITY { + if spread_arity > 0 { + ctx.current_block = arity_tests[spread_arity - 1]; + } + let hit = ctx.block_label(arity_blocks[spread_arity]); + let miss = arity_tests + .get(spread_arity) + .map(|&index| ctx.block_label(index)) + .unwrap_or_else(|| fallback_label.clone()); + let matches = ctx.block().icmp_eq(I32, &arity, &spread_arity.to_string()); + ctx.block().cond_br(&matches, &hit, &miss); + } + + for (spread_arity, &block_idx) in arity_blocks.iter().enumerate() { + ctx.current_block = block_idx; + let mut user_args = fast_fixed.clone(); + for index in 0..spread_arity { + let slot = ctx + .block() + .gep(DOUBLE, &values_alloca, &[(I64, &index.to_string())]); + user_args.push(ctx.block().load(DOUBLE, &slot)); + } + let mut direct_args = Vec::with_capacity(candidate.declared_count + 1); + direct_args.push(fast_recv.clone()); + direct_args.extend(user_args.into_iter().take(candidate.declared_count)); + while direct_args.len() < candidate.declared_count + 1 { + direct_args.push(undefined.clone()); + } + let direct_slices: Vec<(crate::types::LlvmType, &str)> = direct_args + .iter() + .map(|value| (DOUBLE, value.as_str())) + .collect(); + let value = ctx.block().call(DOUBLE, &candidate.target, &direct_slices); + let after = ctx.block().label.clone(); + ctx.block().br(&merge_label); + phi_inputs.push((value, after)); + } + } + + // Every rejection shares the original apply dispatch. The helper only + // constructs its source-ordered argument array; method lookup remains in + // js_native_call_method_apply_by_id so overrides and wrong receivers retain + // the generic semantics. + ctx.current_block = fallback_idx; + let (fixed_ptr, fixed_len) = if fixed_roots.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let fixed_alloca = ctx.func.alloca_entry_array(DOUBLE, fixed_roots.len()); + for (index, &root) in fixed_roots.iter().enumerate() { + let value = roots.reread(ctx, root)?; + let slot = ctx + .block() + .gep(DOUBLE, &fixed_alloca, &[(I64, &index.to_string())]); + ctx.block().store(DOUBLE, &value, &slot); + } + ( + first_element_ptr(ctx, &fixed_alloca, fixed_roots.len()), + fixed_roots.len().to_string(), + ) + }; + let fallback_spread = roots.reread(ctx, spread_root)?; + let args_array = ctx.block().call( + I64, + "js_spread_tail_fallback_args", + &[ + (PTR, &fixed_ptr), + (I64, &fixed_len), + (DOUBLE, &fallback_spread), + ], + ); + let fallback_recv = roots.reread(ctx, recv_root)?; + let dispatch_global = ctx.strings.static_dispatch_global(key_idx); + let method_id = crate::strings::emit_static_dispatch_id(ctx.block(), &dispatch_global); + let fallback_value = ctx.block().call( + DOUBLE, + "js_native_call_method_apply_by_id", + &[ + (DOUBLE, &fallback_recv), + (I64, &method_id), + (I64, &args_array), + ], + ); + let fallback_after = ctx.block().label.clone(); + ctx.block().br(&merge_label); + phi_inputs.push((fallback_value, fallback_after)); + + ctx.current_block = merge_idx; + let incoming: Vec<(&str, &str)> = phi_inputs + .iter() + .map(|(value, label)| (value.as_str(), label.as_str())) + .collect(); + let result = ctx.block().phi(DOUBLE, &incoming); + roots.release(ctx); + + let targets = candidates + .iter() + .map(|candidate| candidate.target.as_str()) + .collect::>() + .join(","); + ctx.record_lowered_value( + "MethodSpreadCall", + None, + "short_packed_spread_direct_call", + &LoweredValue::js_value(result.clone()), + None, + None, + None, + false, + false, + vec![ + "packed_spread_arities=0,1,2,3,4".to_string(), + format!("method={property}"), + format!("direct_targets={targets}"), + "spread_guard=exact_ordinary_packed_array,no_holes,max_length_4".to_string(), + "iterator_guard=builtin_array_iterator,no_own_iterator,no_custom_prototype" + .to_string(), + "method_identity_guard=js_method_direct_shape_class(class_id,shape_id,invalidation_slot)" + .to_string(), + "generic_fallback=js_spread_tail_fallback_args+js_native_call_method_apply_by_id" + .to_string(), + ], + ); + Ok(Some(result)) +} diff --git a/crates/perry-codegen/src/expr/call_spread_short_tests.rs b/crates/perry-codegen/src/expr/call_spread_short_tests.rs new file mode 100644 index 0000000000..03122baba1 --- /dev/null +++ b/crates/perry-codegen/src/expr/call_spread_short_tests.rs @@ -0,0 +1,187 @@ +//! IR ratchets for guarded short packed-spread calls (#8772). + +use perry_hir::types::Type; +use perry_hir::{CallArg, Class, Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +fn param(id: u32, name: &str, ty: Type, default: Option) -> Param { + Param { + id, + name: name.to_string(), + ty, + default, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn reset(id: u32, default: f64) -> Function { + Function { + id, + name: "reset".to_string(), + type_params: Vec::new(), + params: vec![ + param(id + 1, "entity", Type::Any, None), + param(id + 2, "delta", Type::Number, Some(Expr::Number(default))), + ], + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::LocalGet(id + 2)))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn class(id: u32, name: &str, default: f64) -> Class { + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: vec![reset(id + 10, default)], + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn fixture() -> Module { + let mut module = Module::new("issue_8772_short_spread.ts"); + module.classes = vec![class(100, "Position", 1.0), class(200, "Velocity", 2.0)]; + module.functions = vec![Function { + id: 1, + name: "invoke".to_string(), + type_params: Vec::new(), + params: vec![ + param(2, "instance", Type::Any, None), + param(3, "entity", Type::Any, None), + param(4, "args", Type::Array(Box::new(Type::Any)), None), + ], + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::CallSpread { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(2)), + property: "reset".to_string(), + byte_offset: 0, + }), + args: vec![ + CallArg::Expr(Expr::LocalGet(3)), + CallArg::Spread(Expr::LocalGet(4)), + ], + type_args: Vec::new(), + }))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }]; + module.init_kind = ModuleInitKind::Eager; + module +} + +fn emit() -> String { + let opts = crate::CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + String::from_utf8(crate::compile_module(&fixture(), opts).expect("fixture compiles")) + .expect("LLVM IR is UTF-8") +} + +fn function_body<'a>(ir: &'a str, fragment: &str) -> &'a str { + let name = ir + .find(fragment) + .unwrap_or_else(|| panic!("missing function {fragment:?}\n{ir}")); + let start = ir[..name] + .rfind("\ndefine ") + .unwrap_or_else(|| panic!("missing definition for {fragment:?}")); + let tail = &ir[start + 1..]; + let end = tail.find("\n}\n").expect("terminated function definition"); + &tail[..end + 2] +} + +fn named_block<'a>(function: &'a str, label: &str) -> &'a str { + let needle = format!("\n{label}"); + let start = function + .find(&needle) + .map(|offset| offset + 1) + .unwrap_or_else(|| panic!("missing block {label:?}\n{function}")); + let tail = &function[start..]; + let end = tail[1..] + .find("\nshort_spread.") + .map(|offset| offset + 1) + .unwrap_or(tail.len()); + &tail[..end] +} + +#[test] +fn every_short_packed_arity_calls_reset_directly_without_apply() { + let ir = emit(); + let invoke = function_body(&ir, "__invoke("); + assert!(invoke.contains("call i32 @js_short_packed_spread_values(")); + assert!(invoke.contains("call i32 @js_method_direct_shape_class(")); + + for candidate in 0..2 { + for arity in 0..=4 { + let block = named_block( + invoke, + &format!("short_spread.target{candidate}.arity{arity}"), + ); + assert!( + block.contains("call double @perry_method_") && block.contains("__reset("), + "packed arity {arity} must call a selected reset body directly\n{block}" + ); + assert!( + !block.contains("js_native_call_method_apply") + && !block.contains("js_spread_tail_fallback_args"), + "packed arity {arity} must contain no apply machinery\n{block}" + ); + } + } +} + +#[test] +fn guard_misses_retain_one_full_iterator_apply_fallback() { + let ir = emit(); + let invoke = function_body(&ir, "__invoke("); + let fallback = named_block(invoke, "short_spread.fallback"); + assert_eq!( + fallback + .matches("call i64 @js_spread_tail_fallback_args(") + .count(), + 1, + "fallback must materialize fixed+spread exactly once\n{fallback}" + ); + assert_eq!( + fallback + .matches("call double @js_native_call_method_apply_by_id(") + .count(), + 1, + "fallback must retain dynamic method apply\n{fallback}" + ); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 651b15a921..6971f6a516 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -161,6 +161,9 @@ mod write_pic_barrier_tests; // and it now lives outside `crate::expr`. #[cfg(test)] mod call_spread_rooting_tests; +mod call_spread_short; +#[cfg(test)] +mod call_spread_short_tests; #[cfg(test)] mod issue7628_rooting_tests; pub(crate) mod shadow_slot; @@ -1265,10 +1268,17 @@ pub(crate) struct FnCtx<'a> { /// receiver calls. pub local_value_aliases: std::collections::HashMap, + /// Immutable local aliases of producer-proven imported object literals. + /// The value is the original consumer import binding used to look up the + /// cross-module capability. + pub local_imported_object_aliases: std::collections::HashMap, + /// Names of imports that are exported variables (not functions). /// When an ExternFuncRef with one of these names appears as a value, /// the codegen calls the getter instead of wrapping as a closure. pub imported_vars: &'a std::collections::HashSet, + pub imported_object_literals: + &'a std::collections::HashMap, /// Compile-time constant values for specific module globals. When a /// global is a known compile-time constant (e.g., `__platform__`), @@ -1620,6 +1630,22 @@ pub(crate) struct StablePackedLoopFact { pub array_local_id: u32, pub side_exit_label: String, pub descriptor: String, + /// Boxed bound passed to the runtime guard (`-1` requests live length). + pub bound: String, + /// Live-length versions must observe growth as well as shrink. The + /// iteration guard compares its refreshed bound with this admitted value + /// and side-exits when they differ. + pub admitted_bound: String, + pub live_length_bound: bool, + /// Captured receivers cannot keep a raw address across calls in the loop + /// body. They reload the closure slot and revalidate before the first + /// indexed effect of every iteration. + pub revalidate_each_iteration: bool, + /// A nested receiver derived from an outer guarded read may have pure + /// compiler temporaries before its first indexed use. Revalidate at that + /// use, after those temporaries, so none of their runtime loads can leave a + /// stale raw address. + pub revalidate_before_indexed_read: bool, pub live_receiver_handle: Option, /// Admission scanned the complete indexed range and proved every value is /// an untagged IEEE Number. This is requested only when the indexed value @@ -1628,6 +1654,10 @@ pub(crate) struct StablePackedLoopFact { /// Preheader-derived numeric storage bases. Admission proved the complete /// range is raw f64 and the call-free clone keeps these addresses stable. pub numeric_access: Option, + /// Immutable locals initialized from this loop's guarded direct indexed + /// read. They may seed a nested candidate only while this fast-loop fact + /// is active. + pub derived_locals: std::collections::HashSet, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index c0d5068640..fb9f1454f5 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -71,8 +71,9 @@ pub(crate) mod typed_shape; pub mod types; pub use codegen::{ - compile_module, resolve_target_triple, AppMetadata, CompileOptions, FpContractMode, - ImportedClass, NamespaceEntry, NamespaceEntryKind, + compile_module, resolve_target_triple, AppMetadata, CompileOptions, + ExportedObjectLiteralCapability, FpContractMode, ImportedClass, ImportedObjectLiteral, + ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, }; pub use collectors::CjsPreambleCensus; @@ -89,6 +90,14 @@ pub fn exported_proven_this_method_capabilities( collectors::exportable_proven_this_method_capabilities(hir) } +/// Return immutable exported object-literal capabilities for the compile +/// driver to resolve through ESM aliases and re-exports. +pub fn exported_object_literal_method_capabilities( + hir: &perry_hir::Module, +) -> std::collections::HashMap { + collectors::exported_object_literal_capabilities(hir) +} + /// The shadow-stack field offsets generated code bakes into its inline root /// stores (#7088). /// diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index 03762fb4a3..993c1f811a 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -24,6 +24,7 @@ use crate::types::{DOUBLE, I1, I64}; mod dynamic_dispatch; mod fetch_chain; mod helpers; +mod imported_object; mod map_set; mod number_string; mod promise_chain; @@ -145,6 +146,19 @@ pub fn try_lower_property_get_method_call( return Ok(Some(value)); } + // Producer-proven immutable ESM object literals must run before the + // builtin-name routes below: an adapter is allowed to own a method named + // `set`, `toString`, `trim`, etc. + if let Some(value) = imported_object::try_lower_imported_object_method_call( + ctx, + object, + property, + args, + call_byte_offset, + )? { + return Ok(Some(value)); + } + // Number `.toFixed`/`.toPrecision`/`.toExponential`, Buffer/Number // `.toString(encoding|radix)`, and the universal `.toString()` arms. if let Some(value) = diff --git a/crates/perry-codegen/src/lower_call/property_get/imported_object.rs b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs new file mode 100644 index 0000000000..f9712c0ac2 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/property_get/imported_object.rs @@ -0,0 +1,235 @@ +//! Guarded direct calls for stable imported object-literal own methods (#8775). + +use anyhow::Result; +use perry_hir::Expr; + +use crate::expr::{ + emit_typed_feedback_register_site, lower_expr, unbox_to_i64, FnCtx, TypedFeedbackContract, + TypedFeedbackKind, +}; +use crate::native_value::LoweredValue; +use crate::rooting::{any_operand_may_collect, open_rooted_group, Repr}; +use crate::types::{DOUBLE, I32, I64, I8, PTR}; + +fn receiver_binding(ctx: &FnCtx<'_>, object: &Expr) -> Option { + match object { + Expr::ExternFuncRef { name, .. } if ctx.imported_object_literals.contains_key(name) => { + Some(name.clone()) + } + Expr::LocalGet(id) => ctx.local_imported_object_aliases.get(id).cloned(), + _ => None, + } +} + +fn spill_args(ctx: &mut FnCtx<'_>, args: &[String]) -> (String, String) { + if args.is_empty() { + return ("null".to_string(), "0".to_string()); + } + let buf = ctx.func.alloca_entry_array(DOUBLE, args.len()); + for (index, value) in args.iter().enumerate() { + let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &index.to_string())]); + ctx.block().store(DOUBLE, value, &slot); + } + (buf, args.len().to_string()) +} + +pub(super) fn try_lower_imported_object_method_call( + ctx: &mut FnCtx<'_>, + object: &Expr, + property: &str, + args: &[Expr], + call_byte_offset: u32, +) -> Result> { + let Some(binding) = receiver_binding(ctx, object) else { + return Ok(None); + }; + let Some(capability) = ctx.imported_object_literals.get(&binding).cloned() else { + return Ok(None); + }; + let Some(method) = capability + .methods + .iter() + .find(|method| method.name == property && method.param_count == args.len()) + .cloned() + else { + // Function-valued properties, accessors, and arity-changing methods are + // intentionally outside the capability. Let the universal dispatcher + // preserve their dynamic receiver/call semantics. + return Ok(None); + }; + let Some(expected_class_id) = ctx.class_ids.get(&capability.receiver_class_name).copied() + else { + return Ok(None); + }; + let Some(keys_global) = ctx + .class_keys_globals + .get(&capability.receiver_class_name) + .cloned() + else { + return Ok(None); + }; + + // JavaScript evaluates the receiver before arguments. Keep that value (and + // each argument) rooted through both branches, then run all guards after + // argument evaluation so a mutating argument cannot slip past the proof. + let mut roots = open_rooted_group(args.len() + 1); + let recv = lower_expr(ctx, object)?; + let receiver_collects = any_operand_may_collect(ctx, args.iter()); + let receiver_root = roots.adopt_emitted(ctx, Repr::Boxed, &recv, receiver_collects); + for (index, arg) in args.iter().enumerate() { + let collects = any_operand_may_collect(ctx, args[index + 1..].iter()); + roots.lower(ctx, arg, collects)?; + } + let recv = roots.reread_emitted(ctx, receiver_root); + let lowered_args = roots.reread_all(ctx)?; + + let key_index = ctx.strings.intern(property); + let key_entry = ctx.strings.entry(key_index); + let method_guard_slot = (key_entry.dispatch_hash & 0xffff).to_string(); + let dispatch_global = ctx.strings.static_dispatch_global(key_index); + let expected_shape_id = + crate::typed_shape::load_class_shape_id(ctx, &capability.receiver_class_name, &keys_global); + let closure_symbol = format!( + "perry_closure_{}__{}", + capability.source_prefix, method.func_id + ); + let mut closure_params = Vec::with_capacity(method.param_count + 1); + closure_params.push(I64); + closure_params.extend(std::iter::repeat_n(DOUBLE, method.param_count)); + ctx.pending_declares + .push((closure_symbol.clone(), DOUBLE, closure_params)); + + let shape_idx = ctx.new_block("imported_object.shape_guard"); + let method_idx = ctx.new_block("imported_object.method_guard"); + let direct_idx = ctx.new_block("imported_object.direct"); + let fallback_idx = ctx.new_block("imported_object.fallback"); + let merge_idx = ctx.new_block("imported_object.merge"); + let shape_label = ctx.block_label(shape_idx); + let method_label = ctx.block_label(method_idx); + let direct_label = ctx.block_label(direct_idx); + let fallback_label = ctx.block_label(fallback_idx); + let merge_label = ctx.block_label(merge_idx); + + // Exact binding identity is separate from shape: another object may share + // the same anonymous layout and even the same method closure function. + let source_global = format!( + "@perry_global_{}__{}", + capability.source_prefix, capability.source_global_id + ); + let expected_receiver = ctx.block().load(DOUBLE, &source_global); + let recv_bits = ctx.block().bitcast_double_to_i64(&recv); + let expected_bits = ctx.block().bitcast_double_to_i64(&expected_receiver); + let receiver_matches = ctx.block().icmp_eq(I64, &recv_bits, &expected_bits); + ctx.block() + .cond_br(&receiver_matches, &shape_label, &fallback_label); + + ctx.current_block = shape_idx; + crate::lower_call::method_override::emit_inline_direct_method_shape_guard( + ctx, + &recv, + &expected_class_id.to_string(), + &expected_shape_id, + &method_guard_slot, + &method_label, + &fallback_label, + ); + + // The exact shape proves the own data slot. Load it directly, then validate + // the live closure's underlying function identity. Replacement, deletion, + // accessors, and bound/arrow substitutes all fail one of these guards. + ctx.current_block = method_idx; + let closure_value = { + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let blk = ctx.block(); + let recv_handle = blk.and(I64, &recv_bits, crate::nanbox::POINTER_MASK_I64); + let object_ptr = blk.inttoptr(I64, &recv_handle); + let fields = blk.gep(I8, &object_ptr, &[(I64, &header_skip)]); + let slot = blk.gep(DOUBLE, &fields, &[(I64, &method.field_index.to_string())]); + blk.load(DOUBLE, &slot) + }; + let site_id = emit_typed_feedback_register_site( + ctx, + TypedFeedbackKind::ClosureCall, + &format!("imported-object:{binding}.{property}"), + TypedFeedbackContract::closure_direct_call(), + ); + let arity = method.param_count.to_string(); + let guard = ctx.block().call( + I32, + "js_typed_feedback_closure_direct_call_guard", + &[ + (I64, &site_id), + (DOUBLE, &closure_value), + (PTR, &format!("@{closure_symbol}")), + (I32, &arity), + (I32, &arity), + ], + ); + let guard_passes = ctx.block().icmp_ne(I32, &guard, "0"); + ctx.block() + .cond_br(&guard_passes, &direct_label, &fallback_label); + + ctx.current_block = direct_idx; + let closure_handle = unbox_to_i64(ctx.block(), &closure_value); + let mut direct_args: Vec<(crate::types::LlvmType, &str)> = + Vec::with_capacity(lowered_args.len() + 1); + direct_args.push((I64, &closure_handle)); + direct_args.extend(lowered_args.iter().map(|arg| (DOUBLE, arg.as_str()))); + let direct_value = ctx.block().call(DOUBLE, &closure_symbol, &direct_args); + let direct_end = ctx.block().label.clone(); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = fallback_idx; + let method_id = crate::strings::emit_static_dispatch_id(ctx.block(), &dispatch_global); + let (args_ptr, args_len) = spill_args(ctx, &lowered_args); + crate::expr::calls::emit_call_location_at(ctx, call_byte_offset); + let fallback_value = ctx.block().call( + DOUBLE, + "js_native_call_method_by_id", + &[ + (DOUBLE, &recv), + (I64, &method_id), + (PTR, &args_ptr), + (I64, &args_len), + ], + ); + let fallback_end = ctx.block().label.clone(); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = merge_idx; + let result = ctx.block().phi( + DOUBLE, + &[ + (direct_value.as_str(), direct_end.as_str()), + (fallback_value.as_str(), fallback_end.as_str()), + ], + ); + roots.release(ctx); + ctx.record_lowered_value( + "MethodCall", + None, + "imported_object_literal_method_direct_call", + &LoweredValue::js_value(result.clone()), + None, + None, + None, + false, + false, + vec![ + "receiver_provenance=imported_object_literal_metadata".to_string(), + format!("source_export={}", capability.source_export_name), + format!("receiver_class={}", capability.receiver_class_name), + format!("method={property}"), + format!("selected_method_identity={closure_symbol}"), + format!("field_index={}", method.field_index), + "guards=receiver_identity,exact_shape,own_data_slot,function_identity".to_string(), + "generic_dispatch_fallback=js_native_call_method_by_id".to_string(), + ], + ); + Ok(Some(result)) +} diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index b684b90afe..d819b69c58 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -498,6 +498,7 @@ fn imported_remote() -> ImportedClass { ])], source_class_id: Some(55), return_shape_imports: Vec::new(), + object_literal: None, } } diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 2a4e81251e..5449b0d3c0 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -213,6 +213,13 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { module.declare_function("js_array_set_length_strict", VOID, &[I64, DOUBLE]); // Array.from() — js_array_clone handles arrays, Sets, and Maps. module.declare_function("js_array_clone", I64, &[I64]); + // #8772: non-allocating exact packed-array guard for a final spread tail. + // Writes at most four values to caller-owned stack storage and returns + // arity 0..4, or -1 for the generic iterator path. + module.declare_function("js_short_packed_spread_values", I32, &[DOUBLE, PTR]); + // Generic `fixed..., ...spread` materializer used after the short-array or + // guarded-method proof fails. It drives the complete iterator protocol. + module.declare_function("js_spread_tail_fallback_args", I64, &[PTR, I64, DOUBLE]); // #2773: Array.from(source) — throws TypeError for nullish sources, keeps // number/boolean/symbol -> [], otherwise materializes via js_array_clone. // Takes the raw NaN-boxed value so the tag bits survive. diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index d0b622dab4..a7017e3c04 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -703,6 +703,14 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { I32, &[DOUBLE, DOUBLE, I32, PTR], ); + // #8773: same complete packed-loop admission, returning the validated + // live receiver address. Captured bindings may hold an array-growth + // forwarding stub and cannot be rewritten like a compiler-private local. + module.declare_function( + "js_packed_arraylike_loop_guard_live", + I64, + &[DOUBLE, DOUBLE, I32, PTR], + ); // Issue #957: tag-aware dynamic index write. Used by `Expr::IndexUpdate` // codegen to write back the incremented value without rebuilding the // IndexSet dispatch tree. Routes to `js_array_set_index_or_string` for diff --git a/crates/perry-codegen/src/stmt/let_object_facts.rs b/crates/perry-codegen/src/stmt/let_object_facts.rs new file mode 100644 index 0000000000..47d5717160 --- /dev/null +++ b/crates/perry-codegen/src/stmt/let_object_facts.rs @@ -0,0 +1,54 @@ +//! Object-literal facts recorded while lowering immutable local bindings. + +use crate::expr::FnCtx; + +/// #5271: recognize both data-only object literals and method/getter IIFEs so +/// own members win over built-in prototype methods during lowering. +pub(super) fn is_object_literal_init(init: &perry_hir::Expr) -> bool { + use perry_hir::Expr; + match init { + Expr::Object(_) => true, + Expr::Call { callee, args, .. } => { + (matches!(args.first(), Some(Expr::Object(_))) + || matches!( + args.first(), + Some(Expr::New { class_name, .. }) + if class_name.starts_with("__AnonShape_") + )) + && matches!( + callee.as_ref(), + Expr::Closure { params, .. } + if params.first().is_some_and(|p| p.name == "__perry_obj_iife") + ) + } + _ => false, + } +} + +/// Propagate a producer-proven imported object through immutable local aliases. +/// Mutable or reassigned bindings remain generic so replacement stays visible. +pub(super) fn record_imported_object_alias( + ctx: &mut FnCtx<'_>, + id: u32, + init: Option<&perry_hir::Expr>, + mutable: bool, +) { + let binding = (!mutable && !ctx.reassigned_locals.contains(&id)) + .then(|| match init { + Some(perry_hir::Expr::ExternFuncRef { name, .. }) + if ctx.imported_object_literals.contains_key(name) => + { + Some(name.clone()) + } + Some(perry_hir::Expr::LocalGet(source_id)) => { + ctx.local_imported_object_aliases.get(source_id).cloned() + } + _ => None, + }) + .flatten(); + if let Some(binding) = binding { + ctx.local_imported_object_aliases.insert(id, binding); + } else { + ctx.local_imported_object_aliases.remove(&id); + } +} diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 31f9fed412..319f76c777 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1,4 +1,5 @@ use super::let_buffer_views::{math_min_length_buffer_ids, register_noalias_buffer_view}; +use super::let_object_facts::{is_object_literal_init, record_imported_object_alias}; use super::let_stmt_facts::{ buffer_local_alias_source, collect_scalar_class_data, native_i32_alias_source, note_ptr_shape_scalar_replaced, pod_view_count_source, record_array_length_snapshot, @@ -17,24 +18,6 @@ use crate::native_value::{ use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; -/// #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 { - Expr::Object(_) => true, - Expr::Call { callee, args, .. } => { - matches!(args.first(), Some(Expr::Object(_))) - && matches!( - callee.as_ref(), - Expr::Closure { params, .. } - if params.first().is_some_and(|p| p.name == "__perry_obj_iife") - ) - } - _ => false, - } -} - fn is_global_this_value(expr: &perry_hir::Expr) -> bool { matches!(expr, perry_hir::Expr::GlobalGet(_)) || matches!( @@ -91,6 +74,7 @@ pub(crate) fn lower_let( ctx.local_func_ref_ids.insert(id, *func_id); } } + record_imported_object_alias(ctx, id, init, mutable); // Record immutable literal metadata before the module-global and boxed // storage paths return. The loop/PIC matchers reason from the HIR local id, // so the storage representation does not change the const proof. @@ -120,6 +104,7 @@ pub(crate) fn lower_let( } } if let Some(init_expr) = init { + super::stable_packed_loop::record_derived_local(ctx, id, init_expr, mutable); crate::expr::record_local_value_alias_for_write(ctx, id, init_expr); record_array_length_snapshot(ctx, id, init_expr); ctx.guarded_discriminant_aliases.remove(&id); diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index a952e0a96e..9ecca9f867 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5412,6 +5412,11 @@ pub(super) fn lower_for_after_init_with_i32_bound( // Body block. ctx.current_block = body_idx; super::versioned_indexed_loop::emit_iteration_guard(ctx); + let loop_counter_id = match init { + Some(Stmt::Let { id, .. }) => Some(*id), + _ => None, + }; + super::stable_packed_loop::emit_iteration_guard(ctx, loop_counter_id)?; if let Some(cond) = condition { let mut guarded = crate::expr::guarded_buffer_indices_for_condition(ctx, cond, loop_proof_scope_id); diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 49afe99e53..98a682b315 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -21,6 +21,7 @@ mod element_shape_loop; mod element_shape_loop_tests; mod if_stmt; mod let_buffer_views; +mod let_object_facts; mod let_stmt; mod let_stmt_facts; mod loops; diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs index 460a861385..0a3bd01d5e 100644 --- a/crates/perry-codegen/src/stmt/stable_packed_loop.rs +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -1,9 +1,9 @@ //! Guarded loop versions for counted Array and Array-subclass iteration. //! -//! A one-time runtime admission publishes scalar layout facts. The fast copy -//! is entered only after its emitted blocks are proven call-free, so its -//! preheader-cached receiver and storage bases stay valid for the whole copy. -//! Failed admission runs the unchanged generic loop from the current counter. +//! Runtime admission publishes scalar layout facts. Ordinary bindings use a +//! call-free clone whose preheader-cached receiver stays valid throughout; +//! immutable closure captures reload and revalidate at every iteration. A +//! failed admission resumes the unchanged generic loop at the current counter. use anyhow::Result; use perry_hir::{CompareOp, Expr, Stmt, UpdateOp}; @@ -23,6 +23,10 @@ struct Candidate { array_id: u32, bound: LoopBound, numeric_elements: bool, + capture_index: Option, + capture_uses_box: bool, + nested_derived: bool, + nested_requires_access_revalidation: bool, } fn target_below_numeric_operator( @@ -111,16 +115,30 @@ fn stmt_flags(stmt: &Stmt, array_id: u32, counter_id: u32) -> (bool, bool) { /// explicit user call. Later statements may allocate or invoke callbacks: the /// next iteration reloads the root and validates before using it again. fn body_has_safe_leading_read(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { - let Some(first) = body.first() else { - return false; - }; - let (first_target, first_call) = stmt_flags(first, array_id, counter_id); - if !first_target || first_call { - return false; + for (index, stmt) in body.iter().enumerate() { + let (has_target, has_call) = stmt_flags(stmt, array_id, counter_id); + if has_target { + return !has_call + && !body[index + 1..] + .iter() + .any(|later| stmt_flags(later, array_id, counter_id).0); + } + // Compound indexed assignments are lowered into pure receiver/key + // temporaries before the source indexed read. Replaying these local + // copies on a side exit has no observable effect. Keep the admitted + // prefix deliberately narrow; property reads, calls, and writes stay + // generic. + if !matches!( + stmt, + Stmt::Let { + init: Some(Expr::LocalGet(_)), + .. + } + ) { + return false; + } } - !body[1..] - .iter() - .any(|stmt| stmt_flags(stmt, array_id, counter_id).0) + false } fn stmt_contains_break(stmt: &Stmt) -> bool { @@ -163,8 +181,33 @@ fn stmt_contains_break(stmt: &Stmt) -> bool { } } +fn record_capture_rejection(ctx: &mut FnCtx<'_>, array_id: u32, reason: &str) { + let lowered = LoweredValue::js_value("closure_capture_candidate".to_string()); + ctx.record_lowered_value_with_access_mode_and_facts( + "StablePackedArraylikeLoop", + Some(array_id), + "stable_packed_arraylike_capture_rejected", + &lowered, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + None, + None, + Vec::new(), + Vec::new(), + false, + false, + vec![ + "candidate_storage=closure_capture_slot".to_string(), + format!("rejection={reason}"), + "fallback=generic_counted_loop".to_string(), + ], + ); +} + fn match_candidate( - ctx: &FnCtx<'_>, + ctx: &mut FnCtx<'_>, init: Option<&Stmt>, condition: Option<&Expr>, update: Option<&Expr>, @@ -216,30 +259,71 @@ fn match_candidate( _ => return None, }; let receiver = Expr::LocalGet(array_id); - if ctx.reassigned_locals.contains(&array_id) - || ctx.closure_captures.contains_key(&array_id) - || (ctx.locals.contains_key(&array_id) && ctx.boxed_vars.contains(&array_id)) - || (!ctx.locals.contains_key(&array_id) && !ctx.module_globals.contains_key(&array_id)) - // TypedArrays have their own element-width-aware indexed lowering. - // Even though the runtime guard would decline their non-Array header, - // emitting the speculative clone can feed its numeric facts into - // function-wide native-representation selection. In particular a - // Uint32Array XOR then lost the required signed i32 canonicalization - // in the generic copy. Known TypedArrays are never valid candidates, - // so reject them before cloning rather than relying on the guard. - || crate::type_analysis::is_typed_array_expr(ctx, &receiver) - || super::loops::stmts_mutate_local(body, counter_id) - // A fast-loop `break` reaches that clone's exit block. Live-length - // versions use the same block to enter the generic continuation, so - // replaying the current iteration would duplicate preceding effects. - || body.iter().any(stmt_contains_break) - || !body_has_safe_leading_read(body, array_id, counter_id) - // Preserve the existing escape/materialization contract. A dynamic - // call before the loop may have exposed the binding to arbitrary JS; - // the broad #8690 guard must not resurrect a proof deliberately - // retired by that analysis. - || !super::loops::packed_loop_array_binding_is_eligible(ctx, array_id) - { + let capture_index = ctx.closure_captures.get(&array_id).copied(); + let derived_parent = ctx + .stable_packed_loop_facts + .iter() + .rev() + .find(|fact| fact.derived_locals.contains(&array_id)); + let nested_derived = derived_parent.is_some(); + let nested_requires_access_revalidation = derived_parent + .is_some_and(|fact| fact.revalidate_each_iteration || fact.revalidate_before_indexed_read); + let storage_is_available = capture_index.is_some() + || (ctx.locals.contains_key(&array_id) && !ctx.boxed_vars.contains(&array_id)) + || (!ctx.locals.contains_key(&array_id) && ctx.module_globals.contains_key(&array_id)); + let binding_is_eligible = if capture_index.is_some() || nested_derived { + // Capturing the binding is itself an identity exposure in the + // whole-function fact graph. That historical hazard is exactly what + // this version repairs: an immutable capture is reloaded and fully + // guarded at every iteration, so an alias may mutate the object only + // by making the next guard fail to the generic loop. Semantic + // rebinding remains represented in `reassigned_locals` and is rejected + // below; a compiler-only TDZ/hoisting box does not imply mutation. + !ctx.scalar_replaced_arrays.contains_key(&array_id) + } else { + super::loops::packed_loop_array_binding_is_eligible(ctx, array_id) + }; + let leading_read_is_first = body.first().is_some_and(|stmt| { + let (has_target, has_call) = stmt_flags(stmt, array_id, counter_id); + has_target && !has_call + }); + let rejection = if ctx.reassigned_locals.contains(&array_id) { + Some("reassigned_binding") + } else if !storage_is_available { + Some("unavailable_storage") + // TypedArrays have their own element-width-aware indexed lowering. Even + // though the runtime guard would decline their non-Array header, emitting + // the speculative clone can feed its numeric facts into function-wide + // native-representation selection. + } else if crate::type_analysis::is_typed_array_expr(ctx, &receiver) { + Some("known_typed_array") + } else if super::loops::stmts_mutate_local(body, counter_id) { + Some("counter_mutated_in_body") + // A fast-loop `break` reaches that clone's exit block. Live-length + // versions use the same block to enter the generic continuation, so + // replaying the current iteration would duplicate preceding effects. + } else if body.iter().any(stmt_contains_break) { + Some("break_replays_current_iteration") + } else if !body_has_safe_leading_read(body, array_id, counter_id) { + Some("indexed_read_not_safe_and_leading") + // LocalGet prefixes are replay-safe for a nested derived receiver, whose + // guard is emitted at the indexed read. A capture is guarded at iteration + // entry instead, and another captured LocalGet in such a prefix can run a + // GC helper before the cached address is consumed. Keep that shape on the + // generic path until entry guards can be placed after the prefix. + } else if capture_index.is_some() && !leading_read_is_first { + Some("capture_read_after_safepoint_capable_prefix") + // Preserve the existing escape/materialization contract for ordinary + // locals/globals. Captures use their separate guarded eligibility above. + } else if !binding_is_eligible { + Some("binding_not_eligible") + } else { + None + }; + if let Some(reason) = rejection { + if capture_index.is_some() { + record_capture_rejection(ctx, array_id, reason); + } return None; } Some(Candidate { @@ -247,9 +331,38 @@ fn match_candidate( array_id, bound, numeric_elements: leading_read_requires_numeric(body, array_id, counter_id), + capture_index, + capture_uses_box: capture_index.is_some() && ctx.boxed_vars.contains(&array_id), + nested_derived, + nested_requires_access_revalidation, }) } +/// Mark a local whose initializer is the exact direct indexed read admitted by +/// the active stable-packed fact. The mark lives on that fact, so it cannot +/// leak from the fast clone into the generic clone. +pub(super) fn record_derived_local(ctx: &mut FnCtx<'_>, id: u32, init: &Expr, mutable: bool) { + if mutable || ctx.reassigned_locals.contains(&id) { + return; + } + let Expr::IndexGet { object, index } = init else { + return; + }; + let (Expr::LocalGet(array_id), Expr::LocalGet(index_id)) = (object.as_ref(), index.as_ref()) + else { + return; + }; + let Some(fact) = ctx + .stable_packed_loop_facts + .iter_mut() + .rev() + .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *index_id) + else { + return; + }; + fact.derived_locals.insert(id); +} + fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String { let ptr = ctx .block() @@ -257,8 +370,137 @@ fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String ctx.block().load(I64, &ptr) } -fn record_artifacts(ctx: &mut FnCtx<'_>, array_id: u32, receiver: &str) { +/// Derive raw numeric storage bases from a freshly validated receiver. This is +/// used once in ordinary call-free loops and at every iteration entry for a +/// closure capture, where a nested guard/callback may have moved the receiver +/// since the preceding iteration. +fn build_numeric_access( + ctx: &mut FnCtx<'_>, + descriptor: &str, + live_raw: &str, +) -> StablePackedNumericAccess { + let kind = descriptor_word(ctx, descriptor, 0); + let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); + let plain_base = ctx.block().add(I64, live_raw, "8"); + + let element_base = descriptor_word(ctx, descriptor, 4); + let packed_bounds = descriptor_word(ctx, descriptor, 5); + let inline_bound = ctx.block().lshr(I64, &packed_bounds, "32"); + let has_inline = ctx.block().icmp_ult(I64, &element_base, &inline_bound); + let inline_span = ctx.block().sub(I64, &inline_bound, &element_base); + let object_inline_count = ctx.block().select(I1, &has_inline, I64, &inline_span, "0"); + let element_bytes = ctx.block().shl(I64, &element_base, "3"); + let object_header_size = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let inline_offset = ctx.block().add(I64, &object_header_size, &element_bytes); + let object_inline_base = ctx.block().add(I64, live_raw, &inline_offset); + + // Only Array-subclass objects own ObjectMeta. Keep the metadata load + // control-dependent so a plain Array never interprets element bits as a + // pointer. A missing spill is valid when the admitted bound fits inline. + let plain_setup_idx = ctx.new_block("stable_packed.setup.plain"); + let object_setup_idx = ctx.new_block("stable_packed.setup.object"); + let meta_setup_idx = ctx.new_block("stable_packed.setup.meta"); + let setup_merge_idx = ctx.new_block("stable_packed.setup.merge"); + let plain_setup_label = ctx.block_label(plain_setup_idx); + let object_setup_label = ctx.block_label(object_setup_idx); + let meta_setup_label = ctx.block_label(meta_setup_idx); + let setup_merge_label = ctx.block_label(setup_merge_idx); + ctx.block() + .cond_br(&is_plain, &plain_setup_label, &object_setup_label); + + ctx.current_block = plain_setup_idx; + ctx.block().br(&setup_merge_label); + + ctx.current_block = object_setup_idx; + let pointer_size = if crate::target_layout::target_is_ilp32(ctx.target_triple) { + 4 + } else { + 8 + }; + let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) + - pointer_size) + .to_string(); + let meta_addr = ctx.block().add(I64, live_raw, &meta_offset); + let meta_slot = ctx.block().inttoptr(I64, &meta_addr); + let meta_native = ctx + .block() + .load(if pointer_size == 4 { I32 } else { I64 }, &meta_slot); + let meta = if pointer_size == 4 { + ctx.block().zext(I32, &meta_native, I64) + } else { + meta_native + }; + let has_meta = ctx.block().icmp_ne(I64, &meta, "0"); + ctx.block() + .cond_br(&has_meta, &meta_setup_label, &setup_merge_label); + + ctx.current_block = meta_setup_idx; + let meta_ptr = ctx.block().inttoptr(I64, &meta); + let spill_slot = ctx.block().gep(I64, &meta_ptr, &[(I64, "4")]); + let spill = ctx.block().load(I64, &spill_slot); + ctx.block().br(&setup_merge_label); + + ctx.current_block = setup_merge_idx; + let spill = ctx.block().phi( + I64, + &[ + ("0", &plain_setup_label), + ("0", &object_setup_label), + (&spill, &meta_setup_label), + ], + ); + let has_spill = ctx.block().icmp_ne(I64, &spill, "0"); + let safe_spill = ctx.block().select(I1, &has_spill, I64, &spill, live_raw); + let spill_offset = ctx.block().add(I64, &element_bytes, "8"); + let object_spill_base = ctx.block().add(I64, &safe_spill, &spill_offset); + StablePackedNumericAccess { + is_plain, + plain_base, + object_inline_count, + object_inline_base, + object_spill_base, + } +} + +fn record_artifacts(ctx: &mut FnCtx<'_>, candidate: &Candidate, receiver: &str) { + let array_id = candidate.array_id; let lowered = LoweredValue::js_value(receiver.to_string()); + let mut selected_facts = vec![ + "loop_versioning=stable_packed_arraylike".to_string(), + "proof=preheader_scalar_layout".to_string(), + if candidate.capture_index.is_some() { + "candidate_storage=closure_capture_slot".to_string() + } else { + "candidate_storage=addressable_binding".to_string() + }, + if candidate.capture_index.is_some() { + "revalidation=each_iteration_capture_reload".to_string() + } else if candidate.nested_requires_access_revalidation { + "revalidation=before_nested_indexed_read".to_string() + } else { + "revalidation=none_call_free_clone".to_string() + }, + format!( + "guard_identity=stable_packed_arraylike:{}:{}", + candidate.array_id, candidate.counter_id + ), + "side_exit=current_index".to_string(), + ]; + if let Some(capture_index) = candidate.capture_index { + selected_facts.push(format!("capture_index={capture_index}")); + selected_facts.push(format!( + "capture_value_storage={}", + if candidate.capture_uses_box { + "compiler_box" + } else { + "inline_value" + } + )); + } + if candidate.nested_derived { + selected_facts.push("candidate_origin=guarded_outer_index_read".to_string()); + } ctx.record_lowered_value_with_access_mode_and_facts( "StablePackedArraylikeLoop", Some(array_id), @@ -276,12 +518,7 @@ fn record_artifacts(ctx: &mut FnCtx<'_>, array_id: u32, receiver: &str) { Vec::new(), false, false, - vec![ - "loop_versioning=stable_packed_arraylike".to_string(), - "proof=preheader_scalar_layout".to_string(), - "revalidation=none_call_free_clone".to_string(), - "side_exit=current_index".to_string(), - ], + selected_facts, ); ctx.record_lowered_value_with_access_mode_and_facts( "StablePackedArraylikeLoop", @@ -300,6 +537,10 @@ fn record_artifacts(ctx: &mut FnCtx<'_>, array_id: u32, receiver: &str) { false, vec![ "loop_versioning=stable_packed_arraylike_fallback".to_string(), + format!( + "fallback_identity=stable_packed_arraylike:{}:{}", + candidate.array_id, candidate.counter_id + ), "resume=current_index".to_string(), ], ); @@ -313,6 +554,51 @@ pub(crate) fn try_lower_index_get( let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object, index) else { return None; }; + let fact = ctx + .stable_packed_loop_facts + .iter() + .rev() + .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *counter_id)? + .clone(); + if fact.revalidate_before_indexed_read { + let receiver_slot = ctx.locals.get(array_id)?.clone(); + let receiver = ctx.block().load(DOUBLE, &receiver_slot); + let live_raw = ctx.block().call( + I64, + "js_packed_arraylike_loop_guard_live", + &[ + (DOUBLE, &receiver), + (DOUBLE, &fact.bound), + (I32, if fact.numeric_elements { "1" } else { "0" }), + (PTR, &fact.descriptor), + ], + ); + let mut pass = ctx.block().icmp_ne(I64, &live_raw, "0"); + if fact.live_length_bound { + let refreshed_bound = descriptor_word(ctx, &fact.descriptor, 6); + let length_unchanged = ctx + .block() + .icmp_eq(I64, &refreshed_bound, &fact.admitted_bound); + pass = ctx.block().and(I1, &pass, &length_unchanged); + } + let continue_idx = ctx.new_block("stable_packed.indexed_read.derived_valid"); + let continue_label = ctx.block_label(continue_idx); + ctx.block() + .cond_br(&pass, &continue_label, &fact.side_exit_label); + ctx.current_block = continue_idx; + let numeric_access = fact + .numeric_elements + .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw)); + let active = ctx + .stable_packed_loop_facts + .iter_mut() + .rev() + .find(|active| { + active.array_local_id == *array_id && active.counter_local_id == *counter_id + })?; + active.live_receiver_handle = Some(live_raw); + active.numeric_access = numeric_access; + } let fact = ctx .stable_packed_loop_facts .iter() @@ -478,6 +764,58 @@ pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { }) } +/// Refresh a captured receiver at fast-iteration entry. The closure pointer is +/// reloaded through its GC root by ordinary `LocalGet` lowering, then the full +/// runtime admission rechecks identity, forwarding, layout, descriptors, +/// prototype state, packedness, and the admitted range. Only the returned live +/// address is published to direct indexed reads in this iteration. +pub(super) fn emit_iteration_guard( + ctx: &mut FnCtx<'_>, + loop_counter_id: Option, +) -> Result { + let Some(fact) = ctx.stable_packed_loop_facts.last().cloned() else { + return Ok(false); + }; + if !fact.revalidate_each_iteration || loop_counter_id != Some(fact.counter_local_id) { + return Ok(false); + } + + let receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(fact.array_local_id))?; + let live_raw = ctx.block().call( + I64, + "js_packed_arraylike_loop_guard_live", + &[ + (DOUBLE, &receiver), + (DOUBLE, &fact.bound), + (I32, if fact.numeric_elements { "1" } else { "0" }), + (PTR, &fact.descriptor), + ], + ); + let mut pass = ctx.block().icmp_ne(I64, &live_raw, "0"); + if fact.live_length_bound { + let refreshed_bound = descriptor_word(ctx, &fact.descriptor, 6); + let length_unchanged = ctx + .block() + .icmp_eq(I64, &refreshed_bound, &fact.admitted_bound); + pass = ctx.block().and(I1, &pass, &length_unchanged); + } + + let continue_idx = ctx.new_block("stable_packed.iteration.capture_valid"); + let continue_label = ctx.block_label(continue_idx); + ctx.block() + .cond_br(&pass, &continue_label, &fact.side_exit_label); + ctx.current_block = continue_idx; + + let numeric_access = fact + .numeric_elements + .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw)); + if let Some(active) = ctx.stable_packed_loop_facts.last_mut() { + active.live_receiver_handle = Some(live_raw); + active.numeric_access = numeric_access; + } + Ok(true) +} + pub(super) fn lower( ctx: &mut FnCtx<'_>, init: Option<&Stmt>, @@ -508,17 +846,23 @@ pub(super) fn lower( LoopBound::LiveLength => "-1.0".to_string(), }; let descriptor = ctx.func.alloca_entry_array(I64, 7); - let guard = ctx.block().call( - I32, - "js_packed_arraylike_loop_guard", - &[ - (DOUBLE, &receiver), - (DOUBLE, &bound_box), - (I32, if candidate.numeric_elements { "1" } else { "0" }), - (PTR, &descriptor), - ], - ); - let admitted = ctx.block().icmp_ne(I32, &guard, "0"); + let guard_args = [ + (DOUBLE, receiver.as_str()), + (DOUBLE, bound_box.as_str()), + (I32, if candidate.numeric_elements { "1" } else { "0" }), + (PTR, descriptor.as_str()), + ]; + let (admitted, admitted_live_raw) = if candidate.capture_index.is_some() { + let live_raw = ctx + .block() + .call(I64, "js_packed_arraylike_loop_guard_live", &guard_args); + (ctx.block().icmp_ne(I64, &live_raw, "0"), Some(live_raw)) + } else { + let guard = ctx + .block() + .call(I32, "js_packed_arraylike_loop_guard", &guard_args); + (ctx.block().icmp_ne(I32, &guard, "0"), None) + }; // Deliberately left unterminated until the emitted fast clone has been // scanned. The cached receiver below is safe only when no runtime call can // allocate, collect, or revoke an admitted layout while that clone runs. @@ -536,99 +880,21 @@ pub(super) fn lower( descriptor_word(ctx, &descriptor, 6) }; let bound_i32 = ctx.block().trunc(I64, &bound64, I32); - // Reload after the runtime admission call. Once the clone scan succeeds, - // this root cannot move until the clone returns because the clone contains - // no GC-unsafe call or allocation point. - let fast_receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(candidate.array_id))?; - let fast_bits = ctx.block().bitcast_double_to_i64(&fast_receiver); - let fast_raw = ctx - .block() - .and(I64, &fast_bits, crate::nanbox::POINTER_MASK_I64); + // A capture reload is itself a runtime call, so its admission returns the + // post-call live address. Ordinary addressable bindings retain the old + // guard/reload sequence; their reload is a plain load and their clone must + // still pass the call-free scan unless it has explicit access revalidation. + let fast_raw = if let Some(live_raw) = admitted_live_raw { + live_raw + } else { + let fast_receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(candidate.array_id))?; + let fast_bits = ctx.block().bitcast_double_to_i64(&fast_receiver); + ctx.block() + .and(I64, &fast_bits, crate::nanbox::POINTER_MASK_I64) + }; let fast_scan_start = ctx.func.num_blocks(); let numeric_access = if candidate.numeric_elements { - let kind = descriptor_word(ctx, &descriptor, 0); - let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); - let plain_base = ctx.block().add(I64, &fast_raw, "8"); - - let element_base = descriptor_word(ctx, &descriptor, 4); - let packed_bounds = descriptor_word(ctx, &descriptor, 5); - let inline_bound = ctx.block().lshr(I64, &packed_bounds, "32"); - let has_inline = ctx.block().icmp_ult(I64, &element_base, &inline_bound); - let inline_span = ctx.block().sub(I64, &inline_bound, &element_base); - let object_inline_count = ctx.block().select(I1, &has_inline, I64, &inline_span, "0"); - let element_bytes = ctx.block().shl(I64, &element_base, "3"); - let object_header_size = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let inline_offset = ctx.block().add(I64, &object_header_size, &element_bytes); - let object_inline_base = ctx.block().add(I64, &fast_raw, &inline_offset); - - // Only Array-subclass objects own ObjectMeta. Keep the metadata load - // control-dependent so a plain Array never interprets element bits as - // a pointer. A missing spill is valid when the admitted bound fits in - // inline storage; the selected fallback address is then never loaded. - let plain_setup_idx = ctx.new_block("stable_packed.setup.plain"); - let object_setup_idx = ctx.new_block("stable_packed.setup.object"); - let meta_setup_idx = ctx.new_block("stable_packed.setup.meta"); - let setup_merge_idx = ctx.new_block("stable_packed.setup.merge"); - let plain_setup_label = ctx.block_label(plain_setup_idx); - let object_setup_label = ctx.block_label(object_setup_idx); - let meta_setup_label = ctx.block_label(meta_setup_idx); - let setup_merge_label = ctx.block_label(setup_merge_idx); - ctx.block() - .cond_br(&is_plain, &plain_setup_label, &object_setup_label); - - ctx.current_block = plain_setup_idx; - ctx.block().br(&setup_merge_label); - - ctx.current_block = object_setup_idx; - let pointer_size = if crate::target_layout::target_is_ilp32(ctx.target_triple) { - 4 - } else { - 8 - }; - let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) - - pointer_size) - .to_string(); - let meta_addr = ctx.block().add(I64, &fast_raw, &meta_offset); - let meta_slot = ctx.block().inttoptr(I64, &meta_addr); - let meta_native = ctx - .block() - .load(if pointer_size == 4 { I32 } else { I64 }, &meta_slot); - let meta = if pointer_size == 4 { - ctx.block().zext(I32, &meta_native, I64) - } else { - meta_native - }; - let has_meta = ctx.block().icmp_ne(I64, &meta, "0"); - ctx.block() - .cond_br(&has_meta, &meta_setup_label, &setup_merge_label); - - ctx.current_block = meta_setup_idx; - let meta_ptr = ctx.block().inttoptr(I64, &meta); - let spill_slot = ctx.block().gep(I64, &meta_ptr, &[(I64, "4")]); - let spill = ctx.block().load(I64, &spill_slot); - ctx.block().br(&setup_merge_label); - - ctx.current_block = setup_merge_idx; - let spill = ctx.block().phi( - I64, - &[ - ("0", &plain_setup_label), - ("0", &object_setup_label), - (&spill, &meta_setup_label), - ], - ); - let has_spill = ctx.block().icmp_ne(I64, &spill, "0"); - let safe_spill = ctx.block().select(I1, &has_spill, I64, &spill, &fast_raw); - let spill_offset = ctx.block().add(I64, &element_bytes, "8"); - let object_spill_base = ctx.block().add(I64, &safe_spill, &spill_offset); - Some(StablePackedNumericAccess { - is_plain, - plain_base, - object_inline_count, - object_inline_base, - object_spill_base, - }) + Some(build_numeric_access(ctx, &descriptor, &fast_raw)) } else { None }; @@ -637,9 +903,15 @@ pub(super) fn lower( array_local_id: candidate.array_id, side_exit_label: slow_pre_label.clone(), descriptor, + bound: bound_box, + admitted_bound: bound64, + live_length_bound: matches!(candidate.bound, LoopBound::LiveLength), + revalidate_each_iteration: candidate.capture_index.is_some(), + revalidate_before_indexed_read: candidate.nested_requires_access_revalidation, live_receiver_handle: Some(fast_raw), numeric_elements: candidate.numeric_elements, numeric_access, + derived_locals: std::collections::HashSet::new(), }); super::loops::lower_for_after_init_with_i32_bound( ctx, @@ -661,8 +933,11 @@ pub(super) fn lower( && (fast_scan_start..fast_scan_end) .all(|idx| !ctx.func.blocks()[idx].contains_gc_unsafe_call()); ctx.current_block = admission_idx; - if fast_clone_call_free { - record_artifacts(ctx, candidate.array_id, &receiver); + let fast_clone_is_safe = fast_clone_call_free + || candidate.capture_index.is_some() + || candidate.nested_requires_access_revalidation; + if fast_clone_is_safe { + record_artifacts(ctx, &candidate, &receiver); ctx.block() .cond_br(&admitted, &fast_pre_label, &slow_pre_label); } else { diff --git a/crates/perry-ext-events/src/test_async_shims.rs b/crates/perry-ext-events/src/test_async_shims.rs index cb3a466893..108f205115 100644 --- a/crates/perry-ext-events/src/test_async_shims.rs +++ b/crates/perry-ext-events/src/test_async_shims.rs @@ -1,4 +1,5 @@ use perry_ffi::Promise; +use std::ffi::c_void; // Unit-test binaries do not link the host stdlib/runtime archive that normally // provides the perry_ffi async bridge. Keep these test-only shims synchronous. @@ -23,3 +24,12 @@ pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64 f64::from_bits(bits), ); } + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} diff --git a/crates/perry-ext-fetch/src/test_async_shims.rs b/crates/perry-ext-fetch/src/test_async_shims.rs index ed22ecba04..9927c98571 100644 --- a/crates/perry-ext-fetch/src/test_async_shims.rs +++ b/crates/perry-ext-fetch/src/test_async_shims.rs @@ -22,6 +22,15 @@ pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64 ); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-http/src/test_async_shims.rs b/crates/perry-ext-http/src/test_async_shims.rs index e4b5407bbe..6f642e5f85 100644 --- a/crates/perry-ext-http/src/test_async_shims.rs +++ b/crates/perry-ext-http/src/test_async_shims.rs @@ -38,6 +38,15 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( perry_ffi_promise_resolve_bits(promise, invoke(ctx)); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-mysql2/src/lib.rs b/crates/perry-ext-mysql2/src/lib.rs index 7ee3fe054b..7d4ae1fcd5 100644 --- a/crates/perry-ext-mysql2/src/lib.rs +++ b/crates/perry-ext-mysql2/src/lib.rs @@ -20,7 +20,7 @@ use perry_ffi::{ spawn_blocking, take_handle, with_handle, ArrayHeader, Handle, JsPromise, JsValue, ObjectHeader, Promise, StringHeader, }; -use sqlx::mysql::{MySqlConnection, MySqlPool, MySqlPoolOptions, MySqlRow}; +use sqlx::mysql::{MySqlConnection, MySqlDatabaseError, MySqlPool, MySqlPoolOptions, MySqlRow}; use sqlx::pool::PoolConnection; use sqlx::{Column, Connection, MySql, Row, TypeInfo}; use std::sync::Arc; @@ -587,6 +587,86 @@ enum MysqlConnectionTarget { Pool(Arc>>>), } +#[derive(Debug)] +struct MysqlPromiseError { + message: String, + code: Option<&'static str>, + errno: Option, +} + +impl MysqlPromiseError { + fn message(message: impl Into) -> Self { + Self { + message: message.into(), + code: None, + errno: None, + } + } + + fn from_sqlx(context: &str, error: sqlx::Error) -> Self { + let errno = error + .as_database_error() + .and_then(|database| database.try_downcast_ref::()) + .map(MySqlDatabaseError::number); + Self { + message: format!("{context}: {error}"), + code: errno.and_then(mysql2_error_code), + errno, + } + } + + fn reject(self, promise: JsPromise) { + if let Some(errno) = self.errno { + let code = self.code.unwrap_or(""); + let message = self.message; + promise.reject_with(move || { + // MySQL server errors use positive protocol error numbers, as + // mysql2 does, rather than libuv's negative errno convention. + perry_ffi::system_error_value(&message, code, "", i64::from(errno)) + }); + } else { + promise.reject_string(&self.message); + } + } +} + +/// mysql2 exposes symbolic server error names through `.code` and the numeric +/// protocol value through `.errno`. Keep the common SQL/application failures +/// stable here; unknown server numbers still retain `.errno`. +fn mysql2_error_code(errno: u16) -> Option<&'static str> { + Some(match errno { + 1022 => "ER_DUP_KEY", + 1045 => "ER_ACCESS_DENIED_ERROR", + 1048 => "ER_BAD_NULL_ERROR", + 1049 => "ER_BAD_DB_ERROR", + 1050 => "ER_TABLE_EXISTS_ERROR", + 1051 => "ER_BAD_TABLE_ERROR", + 1052 => "ER_NON_UNIQ_ERROR", + 1054 => "ER_BAD_FIELD_ERROR", + 1062 => "ER_DUP_ENTRY", + 1064 => "ER_PARSE_ERROR", + 1146 => "ER_NO_SUCH_TABLE", + 1169 => "ER_DUP_UNIQUE", + 1205 => "ER_LOCK_WAIT_TIMEOUT", + 1213 => "ER_LOCK_DEADLOCK", + 1216 => "ER_NO_REFERENCED_ROW", + 1217 => "ER_ROW_IS_REFERENCED", + 1264 => "ER_WARN_DATA_OUT_OF_RANGE", + 1292 => "ER_TRUNCATED_WRONG_VALUE", + 1364 => "ER_NO_DEFAULT_FOR_FIELD", + 1406 => "ER_DATA_TOO_LONG", + 1451 => "ER_ROW_IS_REFERENCED_2", + 1452 => "ER_NO_REFERENCED_ROW_2", + 1586 => "ER_DUP_ENTRY_WITH_KEY_NAME", + 1830 => "ER_FK_COLUMN_NOT_NULL", + 1834 => "ER_FK_CANNOT_DELETE_PARENT", + 1859 => "ER_DUP_UNKNOWN_IN_INDEX", + 3819 => "ER_CHECK_CONSTRAINT_VIOLATED", + 4025 => "ER_CONSTRAINT_FAILED", + _ => return None, + }) +} + /// Resolve either mysql2 connection handle family without returning a /// registry-backed `'static` reference. The old `get_handle_mut` calls dropped /// DashMap's guard before async work began, so overlapping workers could hold @@ -605,7 +685,7 @@ fn connection_target(handle: Handle) -> Option { async fn execute_query_on_connection( conn: &mut MySqlConnection, request: &QueryRequest, -) -> Result { +) -> Result { let is_select = request.is_row_returning(); if !request.uses_prepared_statement() { @@ -621,8 +701,8 @@ async fn execute_query_on_connection( raw.fetch_all(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; return Ok(QueryOutcome::Rows(raws_from_mysql_rows(rows))); } @@ -631,8 +711,8 @@ async fn execute_query_on_connection( raw.execute(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; return Ok(QueryOutcome::Executed { affected_rows: res.rows_affected(), last_insert_id: res.last_insert_id(), @@ -663,8 +743,8 @@ async fn execute_query_on_connection( query.fetch_all(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; Ok(QueryOutcome::Rows(raws_from_mysql_rows(rows))) } else { let res = tokio::time::timeout( @@ -672,8 +752,8 @@ async fn execute_query_on_connection( query.execute(conn), ) .await - .map_err(|_| "Query timed out".to_string())? - .map_err(|e| format!("Query failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Query timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Query failed", e))?; Ok(QueryOutcome::Executed { affected_rows: res.rows_affected(), last_insert_id: res.last_insert_id(), @@ -684,20 +764,20 @@ async fn execute_query_on_connection( async fn execute_query_on_target( target: MysqlConnectionTarget, request: &QueryRequest, -) -> Result { +) -> Result { match target { MysqlConnectionTarget::Direct(connection) => { let mut slot = connection.lock().await; let conn = slot .as_mut() - .ok_or_else(|| "Connection already closed".to_string())?; + .ok_or_else(|| MysqlPromiseError::message("Connection already closed"))?; execute_query_on_connection(conn, request).await } MysqlConnectionTarget::Pool(connection) => { let mut slot = connection.lock().await; let conn = slot .as_mut() - .ok_or_else(|| "Pool connection released".to_string())?; + .ok_or_else(|| MysqlPromiseError::message("Pool connection released"))?; execute_query_on_connection(conn, request).await } } @@ -721,15 +801,15 @@ pub unsafe extern "C" fn js_mysql2_create_connection(config_f: f64) -> *mut Prom MySqlConnection::connect(&url), ) .await - .map_err(|_| "MySQL connection timed out".to_string())? - .map_err(|e| format!("Failed to connect: {}", e)) + .map_err(|_| MysqlPromiseError::message("MySQL connection timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Failed to connect", e)) }); match result { Ok(conn) => { let handle = register_handle(MysqlConnectionHandle::new(conn)); promise.resolve(JsValue::from_number(handle as f64)); } - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -748,7 +828,9 @@ pub extern "C" fn js_mysql2_connection_end(conn_handle: Handle) -> *mut Promise let result = tokio::runtime::Handle::current().block_on(conn.close()); match result { Ok(()) => promise.resolve_undefined(), - Err(e) => promise.reject_string(&format!("Failed to close: {}", e)), + Err(error) => { + MysqlPromiseError::from_sqlx("Failed to close", error).reject(promise) + } } } else { promise.reject_string("Connection already closed"); @@ -778,9 +860,10 @@ unsafe fn run_connection_query( spawn_blocking(move || { let rows_as_array = request.rows_as_array; - let outcome: Result = - tokio::runtime::Handle::current().block_on(async move { - let target = target.ok_or_else(|| "Invalid connection handle".to_string())?; + let outcome: Result = tokio::runtime::Handle::current() + .block_on(async move { + let target = target + .ok_or_else(|| MysqlPromiseError::message("Invalid connection handle"))?; execute_query_on_target(target, &request).await }); match outcome { @@ -789,7 +872,7 @@ unsafe fn run_connection_query( // thread (worker thread-local arena → dangling on the main thread once // the pooled thread idles out). `out` is plain Send Rust data. Ok(out) => promise.resolve_with(move || outcome_to_jsvalue(&out, rows_as_array)), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -827,36 +910,38 @@ fn run_simple_command(conn_handle: Handle, sql: &'static str) -> *mut Promise { let promise = JsPromise::new(); let raw = promise.as_raw(); spawn_blocking(move || { - let result = tokio::runtime::Handle::current().block_on(async move { - let target = target.ok_or_else(|| "Invalid connection handle".to_string())?; - match target { - MysqlConnectionTarget::Direct(connection) => { - let mut slot = connection.lock().await; - let conn = slot - .as_mut() - .ok_or_else(|| "Connection already closed".to_string())?; - sqlx::raw_sql(sql) - .execute(conn) - .await - .map(|_| ()) - .map_err(|e| format!("{}: {}", sql, e)) - } - MysqlConnectionTarget::Pool(connection) => { - let mut slot = connection.lock().await; - let conn = slot - .as_mut() - .ok_or_else(|| "Pool connection released".to_string())?; - sqlx::raw_sql(sql) - .execute(&mut **conn) - .await - .map(|_| ()) - .map_err(|e| format!("{}: {}", sql, e)) + let result: Result<(), MysqlPromiseError> = + tokio::runtime::Handle::current().block_on(async move { + let target = target + .ok_or_else(|| MysqlPromiseError::message("Invalid connection handle"))?; + match target { + MysqlConnectionTarget::Direct(connection) => { + let mut slot = connection.lock().await; + let conn = slot.as_mut().ok_or_else(|| { + MysqlPromiseError::message("Connection already closed") + })?; + sqlx::raw_sql(sql) + .execute(conn) + .await + .map(|_| ()) + .map_err(|e| MysqlPromiseError::from_sqlx(sql, e)) + } + MysqlConnectionTarget::Pool(connection) => { + let mut slot = connection.lock().await; + let conn = slot.as_mut().ok_or_else(|| { + MysqlPromiseError::message("Pool connection released") + })?; + sqlx::raw_sql(sql) + .execute(&mut **conn) + .await + .map(|_| ()) + .map_err(|e| MysqlPromiseError::from_sqlx(sql, e)) + } } - } - }); + }); match result { Ok(()) => promise.resolve_undefined(), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1141,9 +1226,9 @@ unsafe fn run_pool_query( spawn_blocking(move || { let rows_as_array = request.rows_as_array; - let outcome: Result = - tokio::runtime::Handle::current().block_on(async move { - let pool = pool.ok_or_else(|| "Invalid pool handle".to_string())?; + let outcome: Result = tokio::runtime::Handle::current() + .block_on(async move { + let pool = pool.ok_or_else(|| MysqlPromiseError::message("Invalid pool handle"))?; // Explicitly check out one connection for the whole request so // statement preparation, bind encoding, execution, and result // draining cannot be split across independent pool operations. @@ -1152,8 +1237,8 @@ unsafe fn run_pool_query( pool.acquire(), ) .await - .map_err(|_| "Pool acquire timed out".to_string())? - .map_err(|e| format!("Pool acquire failed: {}", e))?; + .map_err(|_| MysqlPromiseError::message("Pool acquire timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Pool acquire failed", e))?; execute_query_on_connection(&mut conn, &request).await }); match outcome { @@ -1162,7 +1247,7 @@ unsafe fn run_pool_query( // thread (worker thread-local arena → dangling on the main thread once // the pooled thread idles out). `out` is plain Send Rust data. Ok(out) => promise.resolve_with(move || outcome_to_jsvalue(&out, rows_as_array)), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1197,21 +1282,21 @@ pub extern "C" fn js_mysql2_pool_get_connection(pool_handle: Handle) -> *mut Pro let raw = promise.as_raw(); spawn_blocking(move || { let result = tokio::runtime::Handle::current().block_on(async move { - let pool = pool.ok_or_else(|| "Invalid pool handle".to_string())?; + let pool = pool.ok_or_else(|| MysqlPromiseError::message("Invalid pool handle"))?; tokio::time::timeout( Duration::from_secs(DEFAULT_ACQUIRE_TIMEOUT_SECS), pool.acquire(), ) .await - .map_err(|_| "Pool acquire timed out".to_string())? - .map_err(|e| format!("Pool acquire failed: {}", e)) + .map_err(|_| MysqlPromiseError::message("Pool acquire timed out"))? + .map_err(|e| MysqlPromiseError::from_sqlx("Pool acquire failed", e)) }); match result { Ok(conn) => { let h = register_handle(MysqlPoolConnectionHandle::new(conn)); promise.resolve(JsValue::from_number(h as f64)); } - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1251,14 +1336,14 @@ unsafe fn run_pool_conn_query( spawn_blocking(move || { let rows_as_array = request.rows_as_array; - let outcome: Result = - tokio::runtime::Handle::current().block_on(async move { - let connection = - connection.ok_or_else(|| "Invalid pool-connection handle".to_string())?; + let outcome: Result = tokio::runtime::Handle::current() + .block_on(async move { + let connection = connection + .ok_or_else(|| MysqlPromiseError::message("Invalid pool-connection handle"))?; let mut slot = connection.lock().await; let conn = slot .as_mut() - .ok_or_else(|| "Pool connection released".to_string())?; + .ok_or_else(|| MysqlPromiseError::message("Pool connection released"))?; execute_query_on_connection(conn, &request).await }); match outcome { @@ -1267,7 +1352,7 @@ unsafe fn run_pool_conn_query( // thread (worker thread-local arena → dangling on the main thread once // the pooled thread idles out). `out` is plain Send Rust data. Ok(out) => promise.resolve_with(move || outcome_to_jsvalue(&out, rows_as_array)), - Err(e) => promise.reject_string(&e), + Err(error) => error.reject(promise), } }); raw @@ -1299,6 +1384,13 @@ pub unsafe extern "C" fn js_mysql2_pool_connection_execute( mod tests { use super::*; + unsafe fn runtime_string(ptr: *const perry_runtime::StringHeader) -> String { + assert!(!ptr.is_null()); + // SAFETY: callers pass a live runtime string pointer obtained from the + // Error object under test. + unsafe { perry_ffi::copy_string_from_raw(ptr) } + } + #[test] fn config_defaults() { let cfg = MySqlConfig::default(); @@ -1446,4 +1538,68 @@ mod tests { assert_eq!(transaction_sql_for_method("rollback"), Some("ROLLBACK")); assert_eq!(transaction_sql_for_method("release"), None); } + + #[test] + fn mysql_server_error_metadata_matches_mysql2_shape() { + assert_eq!(mysql2_error_code(1062), Some("ER_DUP_ENTRY")); + assert_eq!(mysql2_error_code(1213), Some("ER_LOCK_DEADLOCK")); + assert_eq!(mysql2_error_code(u16::MAX), None); + + let promise = JsPromise::new(); + let raw = promise.as_raw(); + MysqlPromiseError { + message: "Query failed: 1062 duplicate entry".into(), + code: mysql2_error_code(1062), + errno: Some(1062), + } + .reject(promise); + + let reason = perry_runtime::promise::js_promise_reason(raw.cast()); + assert!( + JsValue::from_bits(perry_runtime::error::js_error_is_error(reason).to_bits()).to_bool() + ); + let reason = JsValue::from_bits(reason.to_bits()); + unsafe { + assert_eq!( + jsvalue_to_string(object_field_by_name(reason, "code")).as_deref(), + Some("ER_DUP_ENTRY") + ); + assert_eq!(object_field_by_name(reason, "errno").to_number(), 1062.0); + } + } + + #[test] + fn invalid_connection_rejects_with_error_object() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test tokio runtime"); + let _runtime_guard = runtime.enter(); + let sql = alloc_string("SELECT 1"); + + let promise = unsafe { + js_mysql2_connection_execute( + perry_ffi::INVALID_HANDLE, + sql.as_raw() as *const u8, + f64::from_bits(JsValue::UNDEFINED.bits()), + ) + }; + + assert_eq!(perry_runtime::promise::js_promise_state(promise.cast()), 2); + let reason = perry_runtime::promise::js_promise_reason(promise.cast()); + assert_eq!( + perry_runtime::error::js_error_is_error(reason).to_bits(), + JsValue::from_bool(true).bits() + ); + let error = + JsValue::from_bits(reason.to_bits()).as_pointer::(); + unsafe { + assert_eq!( + runtime_string((*error).message), + "Invalid connection handle" + ); + let stack = runtime_string((*error).stack); + assert!(stack.contains("Error: Invalid connection handle")); + } + } } diff --git a/crates/perry-ext-mysql2/src/test_async_shims.rs b/crates/perry-ext-mysql2/src/test_async_shims.rs index 47945057b6..23ff2d4486 100644 --- a/crates/perry-ext-mysql2/src/test_async_shims.rs +++ b/crates/perry-ext-mysql2/src/test_async_shims.rs @@ -35,6 +35,15 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( perry_ffi_promise_resolve_bits(promise, invoke(ctx)); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-net/src/test_async_shims.rs b/crates/perry-ext-net/src/test_async_shims.rs index fb51459b10..525a79e6d0 100644 --- a/crates/perry-ext-net/src/test_async_shims.rs +++ b/crates/perry-ext-net/src/test_async_shims.rs @@ -34,6 +34,15 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( perry_ffi_promise_resolve_bits(promise, invoke(ctx)); } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(ctx)); +} + #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); diff --git a/crates/perry-ext-sharp/Cargo.toml b/crates/perry-ext-sharp/Cargo.toml index 67e1cabad9..789a7113d4 100644 --- a/crates/perry-ext-sharp/Cargo.toml +++ b/crates/perry-ext-sharp/Cargo.toml @@ -21,3 +21,6 @@ kamadak-exif = "0.6" [dev-dependencies] perry-ffi = { workspace = true, features = ["runtime-link"] } +# Standalone extension tests provide the runtime half of perry-ffi's async +# bridge; the production dependency remains perry-ffi-only. +perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-sharp/src/lib.rs b/crates/perry-ext-sharp/src/lib.rs index 686fc8dc88..6192ce1736 100644 --- a/crates/perry-ext-sharp/src/lib.rs +++ b/crates/perry-ext-sharp/src/lib.rs @@ -13,6 +13,9 @@ use perry_ffi::{ }; use std::io::Cursor; +#[cfg(test)] +mod test_async_shims; + // perry-runtime `#[no_mangle]` symbols (always linked) used to inspect raw // NaN-boxed JS values at the ext-crate boundary: the unified pointer mask // (works for strings AND buffers/objects), the Buffer-registry probe, and @@ -893,6 +896,28 @@ mod tests { assert_eq!(js_sharp_height(-1), 0.0); } + #[test] + fn invalid_handle_async_failure_is_an_error_object() { + let promise = js_sharp_metadata(perry_ffi::INVALID_HANDLE); + assert_eq!(perry_runtime::promise::js_promise_state(promise.cast()), 2); + + let reason = perry_runtime::promise::js_promise_reason(promise.cast()); + assert_eq!( + perry_runtime::error::js_error_is_error(reason).to_bits(), + JsValue::from_bool(true).bits() + ); + let error = + JsValue::from_bits(reason.to_bits()).as_pointer::(); + unsafe { + let message = (*error).message; + assert_eq!( + perry_ffi::copy_string_from_raw(message), + "Invalid sharp handle" + ); + assert!(!(*error).stack.is_null()); + } + } + #[test] fn orientation_6_swaps_dimensions() { // EXIF orientation 6 = rotate 90° CW → W×H becomes H×W. diff --git a/crates/perry-ext-sharp/src/test_async_shims.rs b/crates/perry-ext-sharp/src/test_async_shims.rs new file mode 100644 index 0000000000..a5b2afdab1 --- /dev/null +++ b/crates/perry-ext-sharp/src/test_async_shims.rs @@ -0,0 +1,38 @@ +//! Test-only host shims for the standalone sharp extension test binary. + +use perry_ffi::Promise; +use std::ffi::c_void; + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_new() -> *mut Promise { + perry_runtime::promise::js_promise_new() as *mut Promise +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_resolve_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_runtime::promise::js_promise_resolve( + promise as *mut perry_runtime::Promise, + f64::from_bits(invoke(ctx)), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_runtime::promise::js_promise_reject( + promise as *mut perry_runtime::Promise, + f64::from_bits(invoke(ctx)), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { + invoke(ctx); +} diff --git a/crates/perry-ffi/src/async_runtime.rs b/crates/perry-ffi/src/async_runtime.rs index e5d35e19aa..cbeeb10f98 100644 --- a/crates/perry-ffi/src/async_runtime.rs +++ b/crates/perry-ffi/src/async_runtime.rs @@ -23,11 +23,10 @@ //! - A `JsPromise` is owned by Perry's runtime arena from //! construction onwards. Once resolved or rejected, the //! underlying `Promise` is consumed by the awaiter. -//! - The "bits" passed to [`JsPromise::resolve_string`] / -//! [`JsPromise::reject_string`] are NaN-boxed `JSValue` -//! representations. The safe wrappers in this module produce -//! the right bit pattern so wrapper authors don't need to know -//! the tag values. +//! - The "bits" passed to [`JsPromise::resolve_string`] are NaN-boxed +//! `JSValue` representations. [`JsPromise::reject_string`] copies its +//! message and constructs the corresponding JavaScript `Error` on the main +//! thread, so wrapper authors don't need to know the runtime layout. use std::ffi::c_void; @@ -46,6 +45,11 @@ extern "C" { ctx: *mut c_void, invoke: extern "C" fn(*mut c_void) -> u64, ); + fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, + ); fn perry_ffi_native_async_new(flags: u32) -> *mut NativeAsyncCompletion; fn perry_ffi_native_async_promise(token: *mut NativeAsyncCompletion) -> *mut Promise; fn perry_ffi_native_async_resolve_bits(token: *mut NativeAsyncCompletion, bits: u64) -> i32; @@ -221,18 +225,46 @@ impl JsPromise { unsafe { perry_ffi_promise_resolve_deferred(self.0, ctx, invoke) }; } + /// Reject by building the reason on the **main thread**. + /// + /// This is the rejection-side twin of [`Self::resolve_with`]. It is useful + /// for native failures that need to allocate an Error object or attach + /// structured fields after worker-thread work has completed. + pub fn reject_with(self, f: F) + where + F: FnOnce() -> crate::JsValue + Send + 'static, + { + let boxed: Box u64 + Send> = Box::new(move || f().bits()); + let thin: Box u64 + Send>> = Box::new(boxed); + let ctx = Box::into_raw(thin) as *mut c_void; + + extern "C" fn invoke(ctx: *mut c_void) -> u64 { + let thin: Box u64 + Send>> = + unsafe { Box::from_raw(ctx as *mut Box u64 + Send>) }; + let f: Box u64 + Send> = *thin; + f() + } + + unsafe { perry_ffi_promise_reject_deferred(self.0, ctx, invoke) }; + } + /// Reject with an arbitrary [`crate::JsValue`]. Mirror of /// [`Self::resolve`]. pub fn reject(self, value: crate::JsValue) { unsafe { perry_ffi_promise_reject_bits(self.0, value.bits()) }; } - /// Reject with an error message string. The wrapper layer - /// produces an Error-shaped JSValue downstream; here we just - /// pass the raw message bits. + /// Reject with a JavaScript [`Error`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error) + /// whose `.message` is `message`. + /// + /// The message is copied before returning and the Error is allocated on + /// the main thread. Use [`Self::reject`] when intentionally rejecting with + /// a non-Error JavaScript value. pub fn reject_string(self, message: &str) { - let str_handle = alloc_string(message); - unsafe { perry_ffi_promise_reject_bits(self.0, nanbox_string_bits(str_handle.as_raw())) }; + let message = message.to_owned(); + self.reject_with(move || { + crate::error_value_with_code(&message, "", crate::ErrorKind::Error) + }); } } @@ -299,8 +331,9 @@ impl JsNativeAsyncCompletion { self.resolve_bits(TAG_UNDEFINED) } - /// Reject with a string reason. The runtime copies the bytes immediately and - /// allocates the Perry JS string later on the main thread. + /// Reject with a JavaScript `Error` whose `.message` is `message`. The + /// runtime copies the bytes immediately and allocates the Error later on + /// the main thread. pub fn reject_string(self, message: &str) -> i32 { unsafe { perry_ffi_native_async_reject_string(self.0, message.as_ptr(), message.len()) } } diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index dcf55a28ac..9c1d6d559e 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -199,6 +199,7 @@ impl LoweringContext { mixin_funcs: HashMap::new(), anon_shape_classes: HashMap::new(), anon_shape_fields: HashMap::new(), + prefer_exported_method_shape_seed: false, forward_class_names: std::collections::HashSet::new(), forward_class_decl_depth: std::collections::HashMap::new(), class_renames: std::collections::HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index fe3d5372a8..dee4cf761f 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -632,6 +632,11 @@ fn accessor_key_expr(key: MethodKeyKind) -> Expr { } pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> Result { + // A directly exported object is the producer boundary for #8775. Consume + // the marker here so nested literals continue through their ordinary + // lowering paths. + let prefer_exported_method_shape_seed = + std::mem::take(&mut ctx.prefer_exported_method_shape_seed); // Phase 3: closed-shape object literals lower to `new __AnonShape_N()` // so downstream field access hits the direct-GEP fast path. The // anon class is synthesized as a shape-only class with constructor @@ -1074,7 +1079,8 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R collect_local_refs_expr(value, &mut refs, &mut visited_closures); !refs.contains(¶m_id) }; - let can_emit_static_object = has_method + let can_emit_static_object = !prefer_exported_method_shape_seed + && has_method && !has_spread && !has_accessor && !has_computed @@ -1105,8 +1111,58 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R return Ok(Expr::Object(props)); } - // Pass 2: build the IIFE wrapper. `__o` starts as an empty object - // and each op mutates it in source order. + // Imported-object capabilities need a producer-authoritative non-zero + // class and stable slot order. Keep directly exported, static-key + // method literals in the source-ordered IIFE, but seed it with a + // shape-only anonymous class instead of `{}`. This composes with the + // direct-object optimization above: non-exported literals still skip + // the IIFE, while exported literals retain the metadata required by a + // consumer's guarded direct call. + let static_shape_seed = if prefer_exported_method_shape_seed + && !has_spread + && !has_accessor + && !has_computed + && !has_proto_setter + { + let mut names = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut eligible = true; + for op in &ops { + let name = match op { + SpreadOp::Set { + key: Expr::String(name), + infer_name: false, + .. + } + | SpreadOp::MethodByName { key: name, .. } => name, + _ => { + eligible = false; + break; + } + }; + if seen.insert(name.clone()) { + names.push(name.clone()); + } + } + eligible.then(|| { + let fields: Vec<(String, Type)> = + names.iter().map(|name| (name.clone(), Type::Any)).collect(); + let class_name = ctx.synthesize_anon_shape_class(&fields); + Expr::New { + class_name, + args: names.iter().map(|_| Expr::Undefined).collect(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + } + }) + } else { + None + }; + + // Pass 2: build the IIFE wrapper. `__o` starts as the exported stable + // shape seed when eligible, otherwise as an empty object, and each op + // mutates it in source order. let extern_call = |name: &str, args: Vec| Expr::Call { callee: Box::new(Expr::ExternFuncRef { name: name.to_string(), @@ -1250,7 +1306,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R }; return Ok(Expr::Call { callee: Box::new(closure), - args: vec![Expr::Object(Vec::new())], + args: vec![static_shape_seed.unwrap_or_else(|| Expr::Object(Vec::new()))], type_args: vec![], byte_offset: 0, }); diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index ac9e7aa562..8edf6ba83e 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -767,6 +767,11 @@ pub struct LoweringContext { /// (e.g. recognizing a bundled mysql2 `createPool(config)` by its option /// names) recovers them here. pub(crate) anon_shape_fields: HashMap>, + /// Set while lowering a directly exported binding/default expression. + /// Eligible method literals consume this flag and retain the seeded IIFE + /// representation needed to publish an exact cross-module own-method + /// capability. Ordinary local method literals keep the direct-object path. + pub(crate) prefer_exported_method_shape_seed: bool, /// Class DECLARATION names at the top level of the function body /// currently being lowered. JS resolves a method-body reference to a /// sibling class declared LATER in the same function at call time diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 4ebb06a232..f575561bc8 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -12,6 +12,7 @@ use crate::ir::*; mod namespace; mod native_default_import; pub(super) mod native_profile_import; +mod object_literal; mod typescript; // Re-export moved items so existing `crate::...` / `super::*` call paths keep @@ -21,6 +22,7 @@ use native_default_import::{ canonicalize_native_import_source, is_cjs_style_native_default_import, node_submodule_default_export_key, }; +use object_literal::is_direct_object_literal; pub(crate) fn lower_module_decl( ctx: &mut LoweringContext, @@ -1179,7 +1181,13 @@ pub(crate) fn lower_module_decl( } } - let expr = lower_expr(ctx, init)?; + let previous_shape_seed = ctx.prefer_exported_method_shape_seed; + ctx.prefer_exported_method_shape_seed = var_decl.kind + == ast::VarDeclKind::Const + && is_direct_object_literal(init); + let expr = lower_expr(ctx, init); + ctx.prefer_exported_method_shape_seed = previous_shape_seed; + let expr = expr?; let id = if ctx.pre_registered_module_vars.remove(&name) { ctx.pre_registered_module_var_decls.remove(&name); let id = ctx.lookup_local(&name).unwrap(); @@ -1904,7 +1912,12 @@ pub(crate) fn lower_module_decl( } ast::ModuleDecl::ExportDefaultExpr(export_default_expr) => { // export default - let lowered = lower_expr(ctx, &export_default_expr.expr)?; + let previous_shape_seed = ctx.prefer_exported_method_shape_seed; + ctx.prefer_exported_method_shape_seed = + is_direct_object_literal(&export_default_expr.expr); + let lowered = lower_expr(ctx, &export_default_expr.expr); + ctx.prefer_exported_method_shape_seed = previous_shape_seed; + let lowered = lowered?; // If the expression is a FuncRef, add to exported_functions for proper wrapper generation if let Expr::FuncRef(func_id) = &lowered { diff --git a/crates/perry-hir/src/lower/module_decl/object_literal.rs b/crates/perry-hir/src/lower/module_decl/object_literal.rs new file mode 100644 index 0000000000..3ac093a1bd --- /dev/null +++ b/crates/perry-hir/src/lower/module_decl/object_literal.rs @@ -0,0 +1,21 @@ +//! Structural probe for object literals behind TypeScript-only wrappers. + +use swc_ecma_ast as ast; + +/// Whether `expr` is an object literal once TypeScript-only wrappers +/// (`as`, `!`, `satisfies`, ``, `as const`) and parentheses are peeled off. +pub(super) fn is_direct_object_literal(expr: &ast::Expr) -> bool { + let mut current = expr; + loop { + match current { + ast::Expr::TsAs(wrapper) => current = &wrapper.expr, + ast::Expr::TsNonNull(wrapper) => current = &wrapper.expr, + ast::Expr::TsSatisfies(wrapper) => current = &wrapper.expr, + ast::Expr::TsTypeAssertion(wrapper) => current = &wrapper.expr, + ast::Expr::TsConstAssertion(wrapper) => current = &wrapper.expr, + ast::Expr::Paren(wrapper) => current = &wrapper.expr, + ast::Expr::Object(_) => return true, + _ => return false, + } + } +} diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index d93aeefc75..fef57b543d 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -155,6 +155,55 @@ const computed = { [key]() { return 1; } }; } } +#[test] +fn exported_static_method_literals_keep_a_stable_shape_seed() { + let source = r#" +export const named = { + value: 1, + read() { return this.value; }, +}; +export default { + value: 2, + read() { return this.value; }, +}; +"#; + let module = + perry_parser::parse_typescript(source, "exported-method-object.ts").expect("source parses"); + let hir = super::lower_module( + &module, + "exported-method-object", + "exported-method-object.ts", + ) + .expect("source lowers"); + + for name in ["named", "default"] { + let init = hir + .init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { + name: local_name, + init: Some(init), + .. + } if local_name == name => Some(init), + _ => None, + }) + .unwrap_or_else(|| panic!("missing init for {name}")); + let Expr::Call { callee, args, .. } = init else { + panic!("exported method object must retain its seeded IIFE: {init:#?}"); + }; + assert!(matches!( + callee.as_ref(), + Expr::Closure { params, .. } + if params.first().is_some_and(|param| param.name == "__perry_obj_iife") + )); + assert!(matches!( + args.as_slice(), + [Expr::New { class_name, .. }] if class_name.starts_with("__AnonShape_") + )); + } +} + #[test] fn test_lower_function_registration() { let mut ctx = make_ctx(); diff --git a/crates/perry-runtime/src/array/flat_clone.rs b/crates/perry-runtime/src/array/flat_clone.rs index 2ebb9f8818..f0def80c51 100644 --- a/crates/perry-runtime/src/array/flat_clone.rs +++ b/crates/perry-runtime/src/array/flat_clone.rs @@ -53,6 +53,8 @@ unsafe fn receiver_gc_type(ptr: *const ArrayHeader) -> u8 { /// - `array_proto_iterator_modified`: user code replaced or deleted /// `Array.prototype[Symbol.iterator]`, so the builtin walk is no longer what /// a spread must run. +/// - `object_static_prototype`: `Object.setPrototypeOf(array, custom)` can +/// replace the inherited iterator without touching Array.prototype. /// - `has_own_symbol_property`: the instance carries its OWN `[Symbol.iterator]`, /// which shadows the prototype's. Existence is probed WITHOUT invoking an /// accessor, so falling through to the slow path calls a user getter exactly @@ -86,17 +88,58 @@ pub(crate) fn dense_spread_source(value: f64) -> Option<*const ArrayHeader> { if crate::array::array_proto_iterator_modified() { return None; } - let iter_sym = crate::symbol::well_known_symbol("iterator"); - if iter_sym.is_null() { + if crate::object::prototype_chain::object_static_prototype(arr as usize).is_some() { return None; } - let sym_value = f64::from_bits(crate::value::JSValue::pointer(iter_sym as *const u8).bits()); - if unsafe { crate::symbol::has_own_symbol_property(value, sym_value) } { - return None; + // Do not materialize Symbol.iterator from a guard. If it is not cached, + // user code cannot have installed it as an own key; if it is cached, the + // side-table existence probe below is non-allocating and never invokes an + // accessor. This keeps dense_spread_source usable in call-site guards that + // hold evaluated operands in SSA registers. + let iter_sym = crate::symbol::well_known_symbol_if_cached("iterator"); + if !iter_sym.is_null() { + let sym_value = + f64::from_bits(crate::value::JSValue::pointer(iter_sym as *const u8).bits()); + if unsafe { crate::symbol::has_own_symbol_property(value, sym_value) } { + return None; + } } Some(arr) } +/// Copy a short, exact packed-array spread tail into caller-owned storage. +/// +/// Returns the element count (`0..=4`) on success and `-1` when spread must use +/// the generic iterator path. In addition to [`dense_spread_source`]'s exact +/// ordinary-array proof, this rejects holes: the general dense-copy path may +/// normalize a hole to `undefined`, while a direct-call arm promises that each +/// value came from a present packed slot. +/// +/// This helper is deliberately non-allocating. Generated code evaluates and +/// roots `receiver`, fixed arguments, and the spread expression before calling +/// it, then uses the copied values only when the returned arity is nonnegative. +#[no_mangle] +pub unsafe extern "C" fn js_short_packed_spread_values(value: f64, out: *mut f64) -> i32 { + let Some(arr) = dense_spread_source(value) else { + return -1; + }; + let len = (*arr).length as usize; + if len > 4 || (len != 0 && out.is_null()) { + return -1; + } + let elements = (arr as *const u8).add(std::mem::size_of::()) as *const u64; + for index in 0..len { + let bits = std::ptr::read(elements.add(index)); + if bits == crate::value::TAG_HOLE { + return -1; + } + // GC_STORE_AUDIT(STACK): caller-owned generated stack storage; the + // rooted spread operand keeps copied heap values live during the guard. + std::ptr::write(out.add(index), f64::from_bits(bits)); + } + len as i32 +} + /// Element-copy an array [`dense_spread_source`] has already proven ordinary. /// /// `value` is the NaN-boxed receiver rather than a raw pointer because diff --git a/crates/perry-runtime/src/array/iter_object.rs b/crates/perry-runtime/src/array/iter_object.rs index 201063f0c6..a7ffaab377 100644 --- a/crates/perry-runtime/src/array/iter_object.rs +++ b/crates/perry-runtime/src/array/iter_object.rs @@ -46,6 +46,11 @@ const KIND_VALUES_NULL_DONE: i32 = 3; /// iterator this reads `length` and each indexed property from the Arguments /// object on every step, so mutations made before exhaustion are observable. const KIND_ARGUMENTS_VALUES: i32 = 4; +/// Values iterator over an Array Proxy. The backing field stores the proxy's +/// NaN-boxed registry id rather than an `ArrayHeader` pointer; `.next()` uses +/// live `LengthOfArrayLike` / `Get` operations so proxy traps and mutations are +/// observed with the same timing as `%ArrayIteratorPrototype%.next`. +const KIND_PROXY_VALUES: i32 = 5; /// Clean a NaN-boxed array pointer to a raw `*mut ArrayHeader`, or null. fn unbox_array_ptr(value: f64) -> *mut ArrayHeader { @@ -88,6 +93,9 @@ unsafe fn alloc_iterator(arr_ptr: *mut ArrayHeader, kind: i32) -> f64 { /// `arr.values()` iterator — yields each element value. pub fn array_values_iter(arr_f64: f64) -> f64 { + if crate::proxy::js_proxy_is_proxy(arr_f64) != 0 { + return unsafe { alloc_iterator_backing(arr_f64, KIND_PROXY_VALUES) }; + } let arr_ptr = unbox_array_ptr(arr_f64); if arr_ptr.is_null() { return f64::from_bits(TAG_UNDEFINED); @@ -596,14 +604,15 @@ pub extern "C" fn js_array_entries_iter_obj(arr: *const ArrayHeader) -> i64 { use crate::iter_result::{make_iter_result, make_sqlite_iter_result}; unsafe fn make_pair_array(idx: u32, value: f64) -> f64 { - let pair = crate::array::js_array_alloc(2); - (*pair).length = 2; - let elems = (pair as *mut u8).add(std::mem::size_of::()) as *mut f64; - *elems.add(0) = idx as f64; - *elems.add(1) = value; - crate::array::note_array_slot(pair, 0, (idx as f64).to_bits()); - crate::array::note_array_slot(pair, 1, value.to_bits()); - js_nanbox_pointer(pair as i64) + let scope = crate::gc::RuntimeHandleScope::new(); + let value_h = scope.root_nanbox_f64(value); + let pair_h = scope.root_raw_mut_ptr(crate::array::js_array_alloc(2)); + pair_h.with_mut_ptr(|pair: *mut ArrayHeader| { + (*pair).length = 2; + crate::array::store_array_slot(pair, 0, (idx as f64).to_bits()); + crate::array::store_array_slot(pair, 1, value_h.get_nanbox_u64()); + }); + pair_h.with_mut_ptr(|pair: *mut ArrayHeader| js_nanbox_pointer(pair as i64)) } /// Dispatch `.next()` / `[Symbol.iterator]()` on an array iterator object. @@ -673,6 +682,8 @@ pub unsafe fn dispatch_array_iterator_method( let len = if kind == KIND_ARGUMENTS_VALUES { crate::object::arguments_object_length(backing_ptr as *const ObjectHeader) + } else if kind == KIND_PROXY_VALUES { + super::generic::al_length(backing_f64).clamp(0, u32::MAX as i64) as u32 } else if backing_ptr == 0 { 0 } else { @@ -698,32 +709,38 @@ pub unsafe fn dispatch_array_iterator_method( // field 0, which the collector DOES rewrite, instead of reusing // the pre-store copy. `iter_obj()` re-reads the iterator's own // address from its root for the same reason. - let backing_ptr = - js_nanbox_get_pointer(f64::from_bits(js_object_get_field(iter_obj(), 0).bits())) - as usize; + let backing_f64 = f64::from_bits(js_object_get_field(iter_obj(), 0).bits()); + let backing_ptr = js_nanbox_get_pointer(backing_f64) as usize; let elem = if kind == KIND_ARGUMENTS_VALUES { crate::object::arguments_object_index_value(backing_ptr as *const ObjectHeader, idx) + } else if kind == KIND_PROXY_VALUES { + super::generic::al_get(backing_f64, idx as i64) } else if backing_ptr == 0 { f64::from_bits(TAG_UNDEFINED) } else { crate::array::js_array_get_f64(backing_ptr as *const ArrayHeader, idx) }; + // A Proxy get trap can return a young heap value. Root it before + // either pair or iterator-result construction allocates, then + // reload through the handle at each constructor boundary. + let elem_h = scope.root_nanbox_f64(elem); let value = match kind { - KIND_VALUES | KIND_VALUES_NULL_DONE | KIND_ARGUMENTS_VALUES => { - JSValue::from_bits(elem.to_bits()) + KIND_VALUES | KIND_VALUES_NULL_DONE | KIND_ARGUMENTS_VALUES | KIND_PROXY_VALUES => { + JSValue::from_bits(elem_h.get_nanbox_u64()) } KIND_KEYS => JSValue::number(idx as f64), KIND_ENTRIES => { - let pair = make_pair_array(idx, elem); + let pair = make_pair_array(idx, elem_h.get_nanbox_f64()); JSValue::from_bits(pair.to_bits()) } _ => JSValue::undefined(), }; + let value_h = scope.root_nanbox_u64(value.bits()); if kind == KIND_VALUES_NULL_DONE { - make_sqlite_iter_result(value, false) + make_sqlite_iter_result(JSValue::from_bits(value_h.get_nanbox_u64()), false) } else { - make_iter_result(value, false) + make_iter_result(JSValue::from_bits(value_h.get_nanbox_u64()), false) } } // Iterators are themselves iterable — `[Symbol.iterator]()` on one diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 296e9d883c..face986294 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -784,6 +784,15 @@ pub(crate) fn array_from_spread_value(value: f64) -> *mut ArrayHeader { throw_not_iterable(value()); } + // An Array Proxy is a small registry id, not an `ArrayHeader`. Route it + // through the full GetIterator path before any raw-address classifiers. + // `array_values_iter` stores the proxy value as a dedicated live backing, + // so the default Array iterator performs `Get(length)` / `Get(index)` + // through traps instead of dereferencing the id or unwrapping the target. + if crate::proxy::js_proxy_is_proxy(value()) != 0 { + return js_iterator_to_array(crate::symbol::js_get_iterator(value())); + } + // #7533: the overwhelmingly common spread — an ordinary dense array — is a // straight element copy that nobody can observe as anything else. Take it // before the classification probes and long before the `@@iterator` walk diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 636b9fdc20..bbd80beca2 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -69,7 +69,7 @@ pub use self::element_shape::{ pub(crate) use self::element_shape::{test_element_shape_record_exists, test_serialize}; pub use self::flat_clone::{ js_array_clone, js_array_clone_for_spread, js_array_entries, js_array_flat, - js_array_flat_depth, js_array_keys, js_array_values, + js_array_flat_depth, js_array_keys, js_array_values, js_short_packed_spread_values, }; pub use self::from_concat::{ array_from_full, array_of_full, js_array_concat_variadic, js_array_from_mapped, diff --git a/crates/perry-runtime/src/array/spread_dense_tests.rs b/crates/perry-runtime/src/array/spread_dense_tests.rs index 124b06e19c..235c1682cb 100644 --- a/crates/perry-runtime/src/array/spread_dense_tests.rs +++ b/crates/perry-runtime/src/array/spread_dense_tests.rs @@ -234,3 +234,38 @@ fn a_sparse_array_whose_length_outruns_its_storage_is_not_eligible() { unsafe { (*src).length = (*src).capacity + 1 }; assert!(dense_spread_source(boxed(src)).is_none()); } + +#[test] +fn short_packed_call_guard_copies_empty_and_one_element_arrays() { + let mut out = [f64::NAN; 4]; + let empty = js_array_alloc(0); + assert_eq!( + unsafe { js_short_packed_spread_values(boxed(empty), out.as_mut_ptr()) }, + 0 + ); + + let one = dense(&[37.0]); + assert_eq!( + unsafe { js_short_packed_spread_values(boxed(one), out.as_mut_ptr()) }, + 1 + ); + assert_eq!(out[0], 37.0); +} + +#[test] +fn short_packed_call_guard_rejects_holes_and_oversized_arrays() { + let mut out = [0.0; 4]; + let holey = js_array_push_hole(js_array_push_f64(js_array_alloc(2), 1.0)); + assert_eq!( + unsafe { js_short_packed_spread_values(boxed(holey), out.as_mut_ptr()) }, + -1, + "a hole must take the iterator/apply fallback" + ); + + let oversized = dense(&[1.0, 2.0, 3.0, 4.0, 5.0]); + assert_eq!( + unsafe { js_short_packed_spread_values(boxed(oversized), out.as_mut_ptr()) }, + -1, + "only arities 0 through 4 are specialized" + ); +} diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 2650312f5f..d6b621d803 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -591,13 +591,12 @@ pub extern "C" fn js_packed_arraylike_index_get(receiver: f64, index: f64, cache /// dense_prefix|inline_bound<<32, bound)`. Kind 1 is an ArrayHeader and kind 2 /// is an ObjectHeader Array subclass. A zero return leaves every semantic case /// to the unchanged generic loop. -#[no_mangle] -pub extern "C" fn js_packed_arraylike_loop_guard( +fn packed_arraylike_loop_guard( receiver: f64, bound: f64, require_numeric: i32, out: *mut u64, -) -> i32 { +) -> Option<(i32, *const u8)> { let live_length_bound = bound == -1.0; if out.is_null() || !bound.is_finite() @@ -605,33 +604,54 @@ pub extern "C" fn js_packed_arraylike_loop_guard( || (!live_length_bound && bound.fract() != 0.0) || bound > 16_000_000.0 { - return 0; + return None; } let requested_bound = (!live_length_bound).then_some(bound as u32); let js = JSValue::from_bits(receiver.to_bits()); if !js.is_pointer() { - return 0; + return None; } - let raw = js.as_pointer::(); - let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }) + let source = js.as_pointer::(); + let Some(source_header) = + (unsafe { crate::value::addr_class::try_read_gc_header(source as usize) }) else { - return 0; + return None; }; - if header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { - return 0; - } + // Array growth preserves identity with a forwarding stub. Captured const + // slots cannot be canonicalized like compiler-private locals, so admit one + // validated edge and return the live address to codegen. A longer chain, + // a cross-brand target, or an unreadable target remains a generic-loop + // side exit. Moving GC normally rewrites closure slots, but accepting the + // same representation here also makes forced-evacuation entry fail-safe. + let raw = if source_header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { + if source_header.obj_type != crate::gc::GC_TYPE_ARRAY { + return None; + } + let target = unsafe { crate::gc::forwarding_address(source_header) }; + let target_header = + unsafe { crate::value::addr_class::try_read_gc_header(target as usize) }?; + if target_header.obj_type != crate::gc::GC_TYPE_ARRAY + || target_header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return None; + } + target + } else { + source + }; + let header = unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }?; if header.obj_type == crate::gc::GC_TYPE_ARRAY { if header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 || super::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.load(Ordering::Relaxed) != 0 { - return 0; + return None; } let array = raw.cast::(); let (length, capacity) = unsafe { ((*array).length, (*array).capacity) }; let bound = requested_bound.unwrap_or(length); if bound > length || length > capacity || capacity > 16_000_000 { - return 0; + return None; } if require_numeric != 0 { // The raw-f64 invariant is an O(1) GcHeader bit after its first @@ -639,7 +659,7 @@ pub extern "C" fn js_packed_arraylike_loop_guard( // clears it. Reuse that representation proof instead of walking // the full range on every invocation of the surrounding scan(). if !unsafe { super::header::ensure_array_numeric_raw_f64(array as *mut ArrayHeader) } { - return 0; + return None; } } let gc_word = unsafe { ptr::read_unaligned((raw as *const u8).sub(8).cast::()) }; @@ -653,28 +673,29 @@ pub extern "C" fn js_packed_arraylike_loop_guard( out.add(5).write(0); out.add(6).write(u64::from(bound)); } - return 1; + return Some((1, raw)); } if header.obj_type != crate::gc::GC_TYPE_OBJECT { - return 0; + return None; } - let Some((object, layout)) = dense_layout_for_value(receiver) else { - return 0; + let live_receiver = f64::from_bits(crate::value::js_nanbox_pointer(raw as i64).to_bits()); + let Some((object, layout)) = dense_layout_for_value(live_receiver) else { + return None; }; if !crate::object::object_spill_enabled() || layout.length_slot >= layout.live_inline_slots { - return 0; + return None; } let Some(length) = nonnegative_u32_length(layout_length_value(object, layout)) else { - return 0; + return None; }; let bound = requested_bound.unwrap_or(length); if bound > length || bound > layout.dense_prefix_len || length > 16_000_000 { - return 0; + return None; } if require_numeric != 0 { if !unsafe { ensure_subclass_numeric_prefix(object, layout, bound) } { - return 0; + return None; } } let gc_word = unsafe { ptr::read_unaligned((raw as *const u8).sub(8).cast::()) }; @@ -690,7 +711,35 @@ pub extern "C" fn js_packed_arraylike_loop_guard( ); out.add(6).write(u64::from(bound)); } - 2 + Some((2, raw)) +} + +#[no_mangle] +pub extern "C" fn js_packed_arraylike_loop_guard( + receiver: f64, + bound: f64, + require_numeric: i32, + out: *mut u64, +) -> i32 { + packed_arraylike_loop_guard(receiver, bound, require_numeric, out) + .map(|(kind, _)| kind) + .unwrap_or(0) +} + +/// #8773 capture-safe packed-loop admission. In addition to filling the seven +/// scalar descriptor words, return the live receiver user address. The caller +/// consumes it before the next safepoint and reloads/revalidates on the next +/// iteration; the returned address is never stored as a GC root. +#[no_mangle] +pub extern "C" fn js_packed_arraylike_loop_guard_live( + receiver: f64, + bound: f64, + require_numeric: i32, + out: *mut u64, +) -> i64 { + packed_arraylike_loop_guard(receiver, bound, require_numeric, out) + .map(|(_, raw)| raw as i64) + .unwrap_or(0) } #[cfg(feature = "keepalive-anchors")] @@ -698,6 +747,11 @@ pub extern "C" fn js_packed_arraylike_loop_guard( static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD: extern "C" fn(f64, f64, i32, *mut u64) -> i32 = js_packed_arraylike_loop_guard; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD_LIVE: extern "C" fn(f64, f64, i32, *mut u64) -> i64 = + js_packed_arraylike_loop_guard_live; + #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_JS_PACKED_ARRAYLIKE_INDEX_GET: extern "C" fn(f64, f64, *mut u64) -> f64 = diff --git a/crates/perry-runtime/src/array/tests.rs b/crates/perry-runtime/src/array/tests.rs index 151e794cbf..2a595eb833 100644 --- a/crates/perry-runtime/src/array/tests.rs +++ b/crates/perry-runtime/src/array/tests.rs @@ -74,6 +74,37 @@ fn flattenable_array_ptr_accepts_only_arrays_and_array_proxies() { assert_eq!(flattenable_array_ptr(nested_proxy), array); } +#[test] +fn array_proxy_values_iterator_uses_live_trapped_reads() { + let array = js_array_alloc(4); + js_array_push_f64(array, 7.0); + js_array_push_f64(array, 8.0); + let array_value = boxed_pointer(array as *mut u8); + let handler = crate::object::js_object_alloc(0, 0); + let proxy = crate::proxy::js_proxy_new(array_value, boxed_pointer(handler as *mut u8)); + + let scope = crate::gc::RuntimeHandleScope::new(); + let iter_h = scope.root_nanbox_f64(array_values_iter(proxy)); + let next = || unsafe { + let iter = crate::value::js_nanbox_get_pointer(iter_h.get_nanbox_f64()) + as *mut crate::object::ObjectHeader; + let result = dispatch_array_iterator_method(iter, "next"); + let result = + crate::value::js_nanbox_get_pointer(result) as *const crate::object::ObjectHeader; + ( + f64::from_bits(crate::object::js_object_get_field(result, 0).bits()), + crate::object::js_object_get_field(result, 1).bits() == crate::value::TAG_TRUE, + ) + }; + + assert_eq!(next(), (7.0, false)); + js_array_set_f64(array, 1, 9.0); + assert_eq!(next(), (9.0, false), "indexed Get must stay live"); + js_array_push_f64(array, 10.0); + assert_eq!(next(), (10.0, false), "length Get must stay live"); + assert!(next().1); +} + fn array_keys_contain(keys: *mut ArrayHeader, name: &[u8]) -> bool { let key = string_key(name); for i in 0..js_array_length(keys) { diff --git a/crates/perry-runtime/src/native_abi.rs b/crates/perry-runtime/src/native_abi.rs index 7141671220..e57b2b6b2c 100644 --- a/crates/perry-runtime/src/native_abi.rs +++ b/crates/perry-runtime/src/native_abi.rs @@ -255,6 +255,17 @@ static KEEP_JS_NATIVE_CALL_METHOD_BY_ID: unsafe extern "C-unwind" fn( #[used] static KEEP_JS_NATIVE_CALL_METHOD_APPLY_BY_ID: unsafe extern "C-unwind" fn(f64, i64, i64) -> f64 = crate::object::js_native_call_method_apply_by_id; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SPREAD_TAIL_FALLBACK_ARGS: unsafe extern "C-unwind" fn( + *const f64, + usize, + f64, +) -> i64 = crate::object::js_spread_tail_fallback_args; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_SHORT_PACKED_SPREAD_VALUES: unsafe extern "C" fn(f64, *mut f64) -> i32 = + crate::array::js_short_packed_spread_values; /// Validate and lower a manifest `f32` parameter. #[no_mangle] diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index bc6303a099..37bf382edd 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -536,6 +536,64 @@ pub unsafe extern "C-unwind" fn js_native_call_method_apply_by_id( ) } +/// Materialize `fixed..., ...spread` for the generic branch of a short packed +/// spread callsite. The fast branch has already evaluated all operands; doing +/// the fallback assembly here preserves that source order without re-running +/// an expression, and [`js_array_clone_for_spread`] preserves every observable +/// iterator case (own/prototype overrides, proxies, accessors, and throws). +/// +/// The returned array is consumed immediately by +/// [`js_native_call_method_apply_by_id`]. Every input and both arrays are held +/// in mutable runtime handles because iterator materialization and array pushes +/// can evacuate the nursery. +#[no_mangle] +pub unsafe extern "C-unwind" fn js_spread_tail_fallback_args( + fixed_ptr: *const f64, + fixed_len: usize, + spread: f64, +) -> i64 { + let fixed = if fixed_ptr.is_null() || fixed_len == 0 { + &[][..] + } else { + std::slice::from_raw_parts(fixed_ptr, fixed_len) + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let fixed_handles = scope.root_nanbox_f64_slice(fixed); + let spread_handle = scope.root_nanbox_f64(spread); + + let (_, rooted_spread) = spread_handle.across_nanbox(|| ()); + let spread_array = crate::array::js_array_clone_for_spread(rooted_spread); + let spread_array_handle = scope.root_raw_mut_ptr(spread_array); + let spread_len = spread_array_handle.with_const_ptr(|arr: *const crate::array::ArrayHeader| { + if arr.is_null() { + 0 + } else { + crate::array::js_array_length(arr) as usize + } + }); + let capacity = fixed_len.saturating_add(spread_len).min(u32::MAX as usize) as u32; + let result_handle = scope.root_raw_mut_ptr(crate::array::js_array_alloc(capacity)); + + for value in &fixed_handles { + let (_, rooted_value) = value.across_nanbox(|| ()); + let next = result_handle + .with_mut_ptr(|result| crate::array::js_array_push_f64(result, rooted_value)); + result_handle.set_raw_mut_ptr(next); + } + for index in 0..spread_len { + let value = spread_array_handle + .with_const_ptr(|arr| crate::array::js_array_get_f64(arr, index as u32)); + // The push can collect while `value` is otherwise only a Rust local. + let value_scope = crate::gc::RuntimeHandleScope::new(); + let value_handle = value_scope.root_nanbox_f64(value); + let (_, rooted_value) = value_handle.across_nanbox(|| ()); + let next = result_handle + .with_mut_ptr(|result| crate::array::js_array_push_f64(result, rooted_value)); + result_handle.set_raw_mut_ptr(next); + } + result_handle.with_mut_ptr(|result: *mut crate::array::ArrayHeader| result as i64) +} + /// The numeric property key of an `obj[key](...)` call, as the raw `f64` index /// `js_object_get_index_polymorphic` consumes, or `None` when `key` is not a /// number. Both representations a numeric key can arrive in are accepted: a diff --git a/crates/perry-runtime/src/promise/native_async.rs b/crates/perry-runtime/src/promise/native_async.rs index b0e577dafb..93cba0414a 100644 --- a/crates/perry-runtime/src/promise/native_async.rs +++ b/crates/perry-runtime/src/promise/native_async.rs @@ -191,17 +191,21 @@ fn complete_bits(token: *mut NativeAsyncCompletion, bits: u64, fulfilled: bool) enqueue_with_thread_policy(token, payload, PERRY_NATIVE_ASYNC_OK) } -fn bytes_value_bits(bytes: &[u8]) -> u64 { - let ptr = if bytes.is_empty() { +fn error_value_bits(message: &[u8]) -> u64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let message = if message.is_empty() { crate::string::js_string_from_bytes(std::ptr::null(), 0) } else { - crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32) }; - crate::value::JSValue::string_ptr(ptr).bits() -} - -fn string_value_bits(message: &str) -> u64 { - bytes_value_bits(message.as_bytes()) + let message = scope.root_string_ptr(message); + // `js_error_new_with_message` -> `alloc_error` roots its `message` + // argument in its own handle scope before its first allocation, so a + // scoped raw argument is sound here (#7341 self-rooting entry point). + let error = message.with_mut_ptr::(|ptr| unsafe { + crate::error::js_error_new_with_message(ptr) + }); + crate::value::js_nanbox_pointer(error as i64).to_bits() } fn payload_to_settlement(payload: PendingPayload) -> (bool, u64, u32) { @@ -210,17 +214,17 @@ fn payload_to_settlement(payload: PendingPayload) -> (bool, u64, u32) { PendingPayload::RejectBits(bits) => (false, bits, PERRY_NATIVE_ASYNC_CLEANUP_ON_REJECT), PendingPayload::RejectString(bytes) => ( false, - bytes_value_bits(&bytes), + error_value_bits(&bytes), PERRY_NATIVE_ASYNC_CLEANUP_ON_REJECT, ), PendingPayload::Cancel => ( false, - string_value_bits(DEFAULT_CANCEL_REASON), + error_value_bits(DEFAULT_CANCEL_REASON.as_bytes()), PERRY_NATIVE_ASYNC_CLEANUP_ON_CANCEL, ), PendingPayload::WrongThread => ( false, - string_value_bits(WRONG_THREAD_REASON), + error_value_bits(WRONG_THREAD_REASON.as_bytes()), PERRY_NATIVE_ASYNC_CLEANUP_ON_REJECT, ), } @@ -333,10 +337,11 @@ pub extern "C" fn js_native_async_completion_reject_bits( complete_bits(token, bits, false) } -/// Reject a native async token with caller-owned UTF-8 bytes. +/// Reject a native async token with an Error carrying caller-owned UTF-8 bytes +/// as its message. /// /// The bytes are copied before enqueueing so worker threads do not allocate -/// Perry runtime strings; string allocation happens while draining on the main +/// Perry runtime values; Error allocation happens while draining on the main /// thread. #[no_mangle] pub extern "C" fn js_native_async_completion_reject_string( @@ -681,15 +686,26 @@ mod tests { ) } - unsafe fn assert_heap_string_value(value: f64, expected: &[u8]) { - let value = crate::value::JSValue::from_bits(value.to_bits()); - assert!(value.is_string(), "expected heap string JSValue"); - let ptr = value.as_string_ptr(); + unsafe fn string_bytes(ptr: *const crate::StringHeader) -> Vec { assert!(!ptr.is_null(), "expected non-null string pointer"); - assert_eq!((*ptr).byte_len as usize, expected.len()); + let len = (*ptr).byte_len as usize; let data = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data, expected.len()); - assert_eq!(bytes, expected); + std::slice::from_raw_parts(data, len).to_vec() + } + + unsafe fn assert_error_value(value: f64, expected: &[u8]) { + let value = crate::value::JSValue::from_bits(value.to_bits()); + assert!(value.is_pointer(), "expected Error pointer JSValue"); + let error = value.as_pointer::(); + assert!(crate::error::ptr_is_native_error(error as usize)); + assert_eq!(string_bytes((*error).message), expected); + + let stack = string_bytes((*error).stack); + assert!( + stack.starts_with(b"Error: ") && stack.windows(expected.len()).any(|w| w == expected), + "Error.stack must include the rejection message: {}", + String::from_utf8_lossy(&stack) + ); } #[test] @@ -757,7 +773,7 @@ mod tests { assert_eq!(js_native_async_process_pending(), 1); assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value(super::super::js_promise_reason(promise), &expected); + assert_error_value(super::super::js_promise_reason(promise), &expected); } } @@ -786,7 +802,7 @@ mod tests { assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value( + assert_error_value( super::super::js_promise_reason(promise), DEFAULT_CANCEL_REASON.as_bytes(), ); @@ -881,7 +897,7 @@ mod tests { assert_eq!(js_native_async_process_pending(), 1); assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value( + assert_error_value( super::super::js_promise_reason(promise), WRONG_THREAD_REASON.as_bytes(), ); @@ -917,7 +933,7 @@ mod tests { assert_eq!(js_native_async_process_pending(), 1); assert_eq!(super::super::js_promise_state(promise), 2); unsafe { - assert_heap_string_value( + assert_error_value( super::super::js_promise_reason(promise), WRONG_THREAD_REASON.as_bytes(), ); diff --git a/crates/perry-runtime/src/symbol/iterator.rs b/crates/perry-runtime/src/symbol/iterator.rs index 6d73692e0b..f5efcb6d21 100644 --- a/crates/perry-runtime/src/symbol/iterator.rs +++ b/crates/perry-runtime/src/symbol/iterator.rs @@ -204,6 +204,12 @@ pub extern "C" fn js_iterator_result_validate(result: f64) -> f64 { /// array-memcpy / index-loop arms) so they don't reach this helper. #[no_mangle] pub extern "C" fn js_get_iterator(val_f64: f64) -> f64 { + // Array proxies satisfy IsArray but are small registry ids rather than + // dense `ArrayHeader` pointers. They must reach the ordinary symbol lookup + // below (so a get trap / custom @@iterator remains observable); the + // forwarded builtin iterator now uses KIND_PROXY_VALUES for live trapped + // length/index reads. + let is_proxy = crate::proxy::js_proxy_is_proxy(val_f64) != 0; // `class X extends Array` — the instance is object-backed (a plain // `ObjectHeader` with indexed fields + `length`), but `array_values_iter` // reads a dense `ArrayHeader`. `js_array_is_array` now reports true for such @@ -217,7 +223,9 @@ pub extern "C" fn js_get_iterator(val_f64: f64) -> f64 { let snapshot = crate::array::array_subclass_dense_snapshot(val_f64); return crate::array::array_values_iter(snapshot); } - } else if crate::array::js_array_is_array(val_f64).to_bits() == crate::value::TAG_TRUE { + } else if !is_proxy + && crate::array::js_array_is_array(val_f64).to_bits() == crate::value::TAG_TRUE + { if !crate::array::array_proto_iterator_modified() { return crate::array::array_values_iter(val_f64); } diff --git a/crates/perry-stdlib/src/perry_ffi_async.rs b/crates/perry-stdlib/src/perry_ffi_async.rs index cd18bc3bf6..938bb73028 100644 --- a/crates/perry-stdlib/src/perry_ffi_async.rs +++ b/crates/perry-stdlib/src/perry_ffi_async.rs @@ -184,6 +184,22 @@ pub extern "C" fn perry_ffi_promise_resolve_deferred( }); } +/// `perry_ffi_promise_reject_deferred(promise, ctx, invoke)` — rejection-side +/// twin of [`perry_ffi_promise_resolve_deferred`]. `invoke(ctx)` runs once on +/// the main thread so external bindings can safely allocate Error objects and +/// other structured rejection values after worker-thread work completes. +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut perry_runtime::Promise, + ctx: *mut std::ffi::c_void, + invoke: extern "C" fn(*mut std::ffi::c_void) -> u64, +) { + let ctx_addr = ctx as usize; + async_bridge::queue_deferred_resolution(promise as usize, false, move || { + invoke(ctx_addr as *mut std::ffi::c_void) + }); +} + /// `perry_ffi_spawn_blocking(ctx, invoke)` — run `invoke(ctx)` on /// the global tokio runtime's blocking pool. The caller is expected /// to box a closure into `ctx` before calling, and write a thin diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 3008287468..aa69e8a842 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -569,6 +569,7 @@ fn compute_object_cache_key_with_env( a.name .cmp(&b.name) .then(a.source_prefix.cmp(&b.source_prefix)) + .then(a.local_alias.cmp(&b.local_alias)) }); let mut buf = String::new(); for c in v { @@ -693,6 +694,25 @@ fn compute_object_cache_key_with_env( let mut return_shape_imports = c.return_shape_imports.clone(); return_shape_imports.sort(); buf.push_str(&return_shape_imports.join(",")); + buf.push_str(":object_literal="); + if let Some(object) = &c.object_literal { + buf.push_str(&format!( + "{}@{}:{}:{}:{}:", + object.local_binding, + object.source_prefix, + object.source_export_name, + object.receiver_class_name, + object.source_global_id, + )); + let mut methods = object.methods.clone(); + methods.sort_by(|left, right| left.name.cmp(&right.name)); + for method in methods { + buf.push_str(&format!( + "{}={}/{}/{};", + method.name, method.func_id, method.param_count, method.field_index + )); + } + } buf.push('|'); } h.field("imported_classes", &buf); diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index d31e8f9143..518d813d99 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -373,6 +373,7 @@ fn key_stable_for_nested_type_hashmap_order() { static_field_names: vec![], source_class_id: Some(7), return_shape_imports: vec![], + object_literal: None, }; a = empty_opts(); @@ -431,6 +432,7 @@ fn key_changes_with_imported_class_signature() { static_field_names: vec![], source_class_id: Some(42), return_shape_imports: vec![], + object_literal: None, }); b.imported_classes.push(ImportedClass { name: "Foo".into(), @@ -462,6 +464,7 @@ fn key_changes_with_imported_class_signature() { static_field_names: vec![], source_class_id: Some(42), return_shape_imports: vec![], + object_literal: None, }); assert_ne!( compute_object_cache_key(&a, 1, "0.5.156"), @@ -501,6 +504,7 @@ fn key_changes_with_imported_class_codegen_surface() { static_field_names: vec![], source_class_id: Some(42), return_shape_imports: vec![], + object_literal: None, }; let key_for = |class: ImportedClass| { let mut opts = empty_opts(); @@ -565,6 +569,22 @@ fn key_changes_with_imported_class_codegen_surface() { changed.return_shape_imports = vec!["makeRow".into()]; assert_ne!(base_key, key_for(changed)); + let mut changed = base.clone(); + changed.object_literal = Some(perry_codegen::ImportedObjectLiteral { + local_binding: "adapter".into(), + source_export_name: "default".into(), + source_prefix: "adapter_js".into(), + receiver_class_name: "__ImportedObject_adapter".into(), + source_global_id: 17, + methods: vec![perry_codegen::ImportedObjectLiteralMethod { + name: "run".into(), + func_id: 9, + param_count: 1, + field_index: 2, + }], + }); + assert_ne!(base_key, key_for(changed)); + let mut first_order = base.clone(); first_order.return_shape_imports = vec!["makeB".into(), "makeA".into()]; let mut second_order = base.clone(); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 2cd1e5794d..636f22284a 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -339,6 +339,61 @@ fn imported_class_from_hir( .collect(), source_class_id: Some(class.id), return_shape_imports: Vec::new(), + object_literal: None, + } +} + +fn imported_object_literal_from_capability( + capability: &perry_codegen::ExportedObjectLiteralCapability, + source_prefix: String, + source_export_name: String, + local_binding: String, +) -> perry_codegen::ImportedClass { + // Keep the anonymous shape distinct from user-visible class/import names + // in the consumer while retaining the producer's class name for external + // keys/constructor symbol formation. + let receiver_class_name = format!( + "__ImportedObject_{}_{}_{}", + source_prefix, capability.global_id, local_binding + ); + perry_codegen::ImportedClass { + name: capability.class_name.clone(), + local_alias: Some(receiver_class_name.clone()), + source_prefix: source_prefix.clone(), + constructor_param_count: capability.field_names.len(), + has_own_constructor: true, + constructor_has_rest: false, + has_instance_fields: !capability.field_names.is_empty(), + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: capability.field_names.clone(), + field_types: vec![perry_hir::types::Type::Any; capability.field_names.len()], + source_class_id: Some(capability.class_id), + return_shape_imports: Vec::new(), + object_literal: Some(perry_codegen::ImportedObjectLiteral { + local_binding, + source_export_name, + source_prefix, + receiver_class_name, + source_global_id: capability.global_id, + methods: capability.methods.clone(), + }), } } @@ -929,6 +984,22 @@ pub fn run_with_parse_cache( } } + // Immutable object-literal method capabilities are keyed exactly like the + // other producer facts below. Import resolution later follows barrels to + // this defining path/name before installing a consumer-local binding. + let mut exported_object_literals: BTreeMap< + (String, String), + perry_codegen::ExportedObjectLiteralCapability, + > = BTreeMap::new(); + for (path, hir_module) in &ctx.native_modules { + let path_str = path.to_string_lossy().to_string(); + for (export_name, capability) in + perry_codegen::exported_object_literal_method_capabilities(hir_module) + { + exported_object_literals.insert((path_str.clone(), export_name), capability); + } + } + // Propagate enum re-exports: when module A has `export * from "./B"`, // all enums exported from B should also be accessible via A's path. loop { @@ -4067,6 +4138,20 @@ pub fn run_with_parse_cache( if local_name != exported_name { imported_vars.insert(local_name.clone()); } + + let origin_export_name = resolved_origin_name + .clone() + .unwrap_or_else(|| exported_name.clone()); + if let Some(capability) = exported_object_literals + .get(&(origin_path.clone(), origin_export_name.clone())) + { + imported_classes.push(imported_object_literal_from_capability( + capability, + effective_prefix.clone(), + origin_export_name, + local_name.clone(), + )); + } } // Imported classes diff --git a/crates/perry/tests/issue_8772_short_packed_spread.rs b/crates/perry/tests/issue_8772_short_packed_spread.rs new file mode 100644 index 0000000000..f93c1bb279 --- /dev/null +++ b/crates/perry/tests/issue_8772_short_packed_spread.rs @@ -0,0 +1,282 @@ +//! End-to-end coverage for #8772: guarded direct method calls with a final +//! short packed-array spread tail. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; + +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GEN_GC_EVACUATE", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_GC_VERIFY_EVACUATION", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +fn remove_gc_env_overrides(command: &mut Command) { + for key in GC_ENV_OVERRIDES { + command.env_remove(key); + } +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn fixture_dir() -> PathBuf { + workspace_root().join("test-files/fixtures/issue_8772_short_packed_spread") +} + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn target_debug_dir() -> PathBuf { + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")); + target.join("debug") +} + +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static") + .arg("-p") + .arg("perry-stdlib-static"); + let output = command.output().expect("build static runtime archives"); + assert_success("static runtime build", &output); + }); +} + +fn assert_success(label: &str, output: &Output) { + assert!( + output.status.success(), + "{label} failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn copy_fixture(dir: &Path, file: &str) { + std::fs::copy(fixture_dir().join(file), dir.join(file)) + .unwrap_or_else(|error| panic!("copy {file}: {error}")); +} + +fn compile(dir: &Path, entry: &str) -> PathBuf { + ensure_runtime_archive(); + let output = dir.join(format!("{entry}.bin")); + let mut command = Command::new(perry_bin()); + command + .current_dir(dir) + .arg("compile") + .arg(entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--trace") + .arg("llvm") + .arg("--opt-report=json") + .arg("--explain-lowering") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", target_debug_dir()); + remove_gc_env_overrides(&mut command); + let compiled = command.output().expect("compile fixture"); + assert_success("perry compile", &compiled); + output +} + +fn run(binary: &Path, dir: &Path, moving_gc: bool) -> String { + let mut command = Command::new(binary); + command.current_dir(dir); + remove_gc_env_overrides(&mut command); + if moving_gc { + command + .env("PERRY_GC_SCAVENGE", "1") + .env("PERRY_GC_SCAVENGE_NURSERY_MB", "1") + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1") + .env("PERRY_GC_INCREMENTAL", "0"); + } + let output = command.output().expect("run compiled fixture"); + assert_success("compiled fixture", &output); + String::from_utf8(output.stdout).expect("fixture stdout is UTF-8") +} + +fn run_node(dir: &Path, entry: &str) -> String { + let output = run_node_output(dir, entry); + assert_success("Node oracle", &output); + String::from_utf8(output.stdout).expect("Node stdout is UTF-8") +} + +fn run_node_output(dir: &Path, entry: &str) -> Output { + Command::new("node") + .current_dir(dir) + .arg("--experimental-strip-types") + .arg(entry) + .output() + .expect("run Node oracle") +} + +fn run_failure(binary: &Path, dir: &Path, moving_gc: bool) -> Output { + let mut command = Command::new(binary); + command.current_dir(dir); + remove_gc_env_overrides(&mut command); + if moving_gc { + command + .env("PERRY_GC_SCAVENGE", "1") + .env("PERRY_GC_SCAVENGE_NURSERY_MB", "1") + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1") + .env("PERRY_GC_INCREMENTAL", "0"); + } + let output = command.output().expect("run failing compiled fixture"); + assert!( + !output.status.success(), + "compiled fixture unexpectedly passed" + ); + output +} + +fn read_lowering_artifacts(dir: &Path) -> String { + let lowering = dir.join(".perry-trace/lowering"); + let run_dir = std::fs::read_dir(&lowering) + .expect("read lowering directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.is_dir()) + .expect("lowering run directory"); + std::fs::read_dir(run_dir) + .expect("read lowering run") + .filter_map(Result::ok) + .filter(|entry| { + entry.file_name().to_str().is_some_and(|name| { + name.starts_with("perry_native_reps_") && name.ends_with(".json") + }) + }) + .map(|entry| std::fs::read_to_string(entry.path()).expect("read lowering artifact")) + .collect::() +} + +fn function_ir<'a>(ir: &'a str, fragment: &str) -> &'a str { + let name = ir + .find(fragment) + .unwrap_or_else(|| panic!("missing function {fragment:?}")); + let start = ir[..name] + .rfind("\ndefine ") + .unwrap_or_else(|| panic!("missing definition for {fragment:?}")); + let tail = &ir[start + 1..]; + let end = tail.find("\n}\n").expect("terminated function definition"); + &tail[..end + 2] +} + +fn named_blocks(function: &str, prefixes: &[&str]) -> String { + let mut selected = false; + let mut result = String::new(); + for line in function.lines() { + if !line.starts_with([' ', '\t']) && line.contains(':') { + selected = prefixes.iter().any(|prefix| line.contains(prefix)); + } + if selected { + result.push_str(line); + result.push('\n'); + } + } + result +} + +#[test] +fn repro_has_direct_empty_and_one_arms_and_matches_node_under_moving_gc() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path(), "main.ts"); + let binary = compile(temp.path(), "main.ts"); + + let node = run_node(temp.path(), "main.ts"); + assert_eq!(node, "90000900000\n"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); + + let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/main_ts.ll")) + .expect("read main LLVM IR"); + let invoke = function_ir(&ir, "__invoke("); + assert!(invoke.contains("call i32 @js_short_packed_spread_values(")); + assert!(invoke.contains("call i32 @js_method_direct_shape_class(")); + let direct = named_blocks( + invoke, + &[ + "short_spread.target0.arity0", + "short_spread.target0.arity1", + "short_spread.target1.arity0", + "short_spread.target1.arity1", + ], + ); + assert!(direct.contains("call double @perry_method_") && direct.contains("__reset(")); + assert!( + !direct.contains("js_native_call_method_apply") + && !direct.contains("js_spread_tail_fallback_args") + ); + let fallback = named_blocks(invoke, &["short_spread.fallback"]); + assert!(fallback.contains("js_spread_tail_fallback_args")); + assert!(fallback.contains("js_native_call_method_apply_by_id")); + + let artifacts = read_lowering_artifacts(temp.path()); + for required in [ + "packed_spread_arities=0,1,2,3,4", + "method=reset", + "spread_guard=exact_ordinary_packed_array,no_holes,max_length_4", + "method_identity_guard=js_method_direct_shape_class(class_id,shape_id,invalidation_slot)", + "generic_fallback=js_spread_tail_fallback_args+js_native_call_method_apply_by_id", + ] { + assert!( + artifacts.contains(required), + "explain-lowering artifact must contain {required:?}\n{artifacts}" + ); + } +} + +#[test] +fn every_exotic_spread_and_dispatch_case_matches_node_under_moving_gc() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path(), "semantics.ts"); + let binary = compile(temp.path(), "semantics.ts"); + let node = run_node(temp.path(), "semantics.ts"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); +} + +#[test] +fn throwing_iterator_matches_node_under_moving_gc() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path(), "throwing.ts"); + let binary = compile(temp.path(), "throwing.ts"); + + let node = run_node_output(temp.path(), "throwing.ts"); + assert!(!node.status.success(), "Node fixture unexpectedly passed"); + assert!(String::from_utf8_lossy(&node.stderr).contains("iterator-boom")); + + for moving_gc in [false, true] { + let perry = run_failure(&binary, temp.path(), moving_gc); + let stderr = String::from_utf8_lossy(&perry.stderr); + assert!( + stderr.contains("iterator-boom"), + "Perry error must preserve iterator throw, got:\n{stderr}" + ); + } +} diff --git a/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs new file mode 100644 index 0000000000..23bfdac554 --- /dev/null +++ b/crates/perry/tests/issue_8773_closure_capture_packed_loops.rs @@ -0,0 +1,337 @@ +//! Regression coverage for #8773: immutable closure-captured packed Arrays and +//! Array subclasses, including an inner array derived from the guarded outer +//! indexed read, receive direct-load fast loop versions with generic side exits. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn runtime_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("target") + .join(if cfg!(debug_assertions) { + "debug" + } else { + "release" + }) +} + +fn compile(dir: &Path, source: &str, retain_artifacts: bool) -> (PathBuf, String) { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let mut command = Command::new(perry_bin()); + command + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--no-auto-optimize") + .env("PERRY_RUNTIME_DIR", runtime_dir()); + if retain_artifacts { + command + .env("PERRY_LLVM_KEEP_IR", "1") + .env("PERRY_NATIVE_REPS", "1") + .env("PERRY_NATIVE_REPS_DIR", dir.join("native-reps")); + } + let compiled = command.output().expect("run perry compile"); + assert!( + compiled.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compiled.stdout), + String::from_utf8_lossy(&compiled.stderr) + ); + ( + output, + String::from_utf8_lossy(&compiled.stderr).into_owned(), + ) +} + +fn run(binary: &Path, dir: &Path, moving_gc: bool) -> Output { + let mut command = Command::new(binary); + command.current_dir(dir); + if moving_gc { + command + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1"); + } + command.output().expect("run compiled fixture") +} + +fn assert_output(output: &Output, expected: &str, moving_gc: bool) { + assert!( + output.status.success(), + "fixture failed with moving_gc={moving_gc}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout), expected); +} + +fn named_blocks(ir: &str, prefixes: &[&str]) -> String { + let mut selected = false; + let mut result = String::new(); + for line in ir.lines() { + if !line.starts_with([' ', '\t']) && line.contains(':') { + selected = prefixes.iter().any(|prefix| line.contains(prefix)); + } + if selected { + result.push_str(line); + result.push('\n'); + } + } + result +} + +#[test] +fn nested_closure_capture_uses_live_guards_and_direct_fast_reads() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +class Query extends Array {} +class Archetype extends Array {} + +function setup(entityCount: number) { + const query = new Query(); + const archetype = new Archetype(); + for (let i = 0; i < entityCount; i++) archetype.push(i); + query.push(archetype); + const values = new Uint32Array(entityCount); + + function system() { + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + values[current[j]] += 1; + } + } + } + + return () => { + system(); + return values[0]; + }; +} + +const run = setup(1_000); +let checksum = 0; +for (let i = 0; i < 2_000; i++) checksum = run(); +console.log(checksum); +"#; + let (binary, stderr) = compile(dir.path(), source, true); + for moving_gc in [false, true] { + assert_output(&run(&binary, dir.path(), moving_gc), "2000\n", moving_gc); + } + + let ir_path = stderr + .lines() + .find_map(|line| line.split("kept LLVM IR: ").nth(1)) + .map(str::trim) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("PERRY_LLVM_KEEP_IR did not report an IR path\n{stderr}")); + let ir = std::fs::read_to_string(ir_path).expect("read kept LLVM IR"); + let artifact_text = std::fs::read_dir(dir.path().join("native-reps")) + .expect("read native-reps directory") + .map(|entry| { + std::fs::read_to_string(entry.expect("native-reps entry").path()) + .expect("read native-reps artifact") + }) + .collect::(); + let stable_diagnostics = artifact_text + .lines() + .filter(|line| { + line.contains("stable_packed") + || line.contains("candidate_") + || line.contains("capture_") + || line.contains("rejection=") + }) + .collect::>() + .join("\n"); + let fast_preheaders = ir + .lines() + .filter(|line| line.starts_with("stable_packed.loop.fast.preheader") && line.ends_with(':')) + .count(); + assert!( + fast_preheaders >= 2, + "both captured outer and nested-derived inner loops need fast versions\n{stable_diagnostics}" + ); + assert!(ir.contains("stable_packed.iteration.capture_valid")); + assert!(ir.contains("call i64 @js_packed_arraylike_loop_guard_live(")); + + let fast_blocks = named_blocks(&ir, &["stable_packed", "for.stable_packed_fast"]); + assert!( + fast_blocks.contains("load double"), + "fast versions must contain direct element loads\n{fast_blocks}" + ); + assert!( + !fast_blocks.contains("js_packed_arraylike_index_get") + && !fast_blocks.contains("js_object_get_index_polymorphic"), + "fast versions must not retain indexed-read helpers\n{fast_blocks}" + ); + assert!( + ir.contains("js_packed_arraylike_index_get"), + "the unchanged generic fallback must remain in the function" + ); + + for required in [ + "candidate_storage=closure_capture_slot", + "revalidation=each_iteration_capture_reload", + "candidate_origin=guarded_outer_index_read", + "guard_identity=stable_packed_arraylike:", + "fallback_identity=stable_packed_arraylike:", + ] { + assert!( + artifact_text.contains(required), + "lowering explanation must identify `{required}`\n{stable_diagnostics}" + ); + } +} + +#[test] +fn captured_negative_shapes_preserve_generic_semantics() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +class Query extends Array {} +class Archetype extends Array {} + +function make(source: any) { + const query = source; + return () => { + let text = ""; + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + text += current[j] + ","; + } + } + return text; + }; +} + +function mutableCapture() { + let query: any = [[1, 2]]; + const scan = () => { + let sum = 0; + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) sum += current[j]; + } + return sum; + }; + query = [[7, 8]]; + return scan; +} + +function prefixedCapture() { + const query = [0, 1]; + const values = new Uint32Array(2); + return () => { + for (let i = 0, length = query.length; i < length; i++) values[query[i]] += 1; + return values[0] + values[1]; + }; +} + +const dense: any = new Query(); +const row: any = new Archetype(); +row.push(1); row.push(2); row.push(3); dense.push(row); +console.log("dense=" + make(dense)()); + +const hole: any[] = [[4, 5, 6]]; +delete hole[0][1]; +console.log("hole=" + make(hole)()); + +const accessor: any[] = [[7, 8]]; +Object.defineProperty(accessor[0], "1", { get() { return 41; } }); +console.log("accessor=" + make(accessor)()); + +const proxy = new Proxy([[9, 10]], { + get(target: any, key: any) { return Reflect.get(target, key); } +}); +console.log("proxy=" + make(proxy)()); + +const resized: any[] = [[11, 12], [13]]; +const resizedScan = make(resized); +resized.push([14, 15]); +resized.length = 2; +console.log("resized=" + resizedScan()); + +const grown: any[] = [[16]]; +const grownScan = make(grown); +const grownAlias = grown; +grownAlias.push([17, 18]); +console.log("grown=" + grownScan()); + +const shrunk: any[] = [[19], [20, 21]]; +const shrunkScan = make(shrunk); +shrunk.length = 1; +console.log("shrunk=" + shrunkScan()); + +console.log("rebound=" + mutableCapture()()); +console.log("prefixed=" + prefixedCapture()()); + +const moved: any[] = [[22, 23, 24]]; +const movedScan = make(moved); +gc(); +console.log("moved=" + movedScan()); + +class PrototypeRow extends Array {} +const prototypeQuery: any = new Query(); +const prototypeRow: any = new PrototypeRow(); +prototypeRow.length = 1; +Object.defineProperty(PrototypeRow.prototype, "0", { get() { return 25; } }); +prototypeQuery.push(prototypeRow); +console.log("prototype=" + make(prototypeQuery)()); +"#; + let (binary, _) = compile(dir.path(), source, false); + let expected = "dense=1,2,3,\n\ + hole=4,undefined,6,\n\ + accessor=7,41,\n\ + proxy=9,10,\n\ + resized=11,12,13,\n\ + grown=16,17,18,\n\ + shrunk=19,\n\ + rebound=15\n\ + prefixed=2\n\ + moved=22,23,24,\n\ + prototype=25,\n"; + for moving_gc in [false, true] { + assert_output(&run(&binary, dir.path(), moving_gc), expected, moving_gc); + } +} + +#[test] +fn captured_accessor_exception_remains_observable() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +function make() { + const query: any[] = [[16]]; + Object.defineProperty(query[0], "0", { get() { throw new Error("capture-getter"); } }); + return () => { + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) console.log(current[j]); + } + }; +} +make()(); +"#; + let (binary, _) = compile(dir.path(), source, false); + for moving_gc in [false, true] { + let output = run(&binary, dir.path(), moving_gc); + assert!( + !output.status.success(), + "throwing getter unexpectedly succeeded with moving_gc={moving_gc}" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("capture-getter"), + "uncaught exception lost getter identity with moving_gc={moving_gc}:\n{stderr}" + ); + } +} diff --git a/crates/perry/tests/issue_8775_imported_object_specialization.rs b/crates/perry/tests/issue_8775_imported_object_specialization.rs new file mode 100644 index 0000000000..b89262d24f --- /dev/null +++ b/crates/perry/tests/issue_8775_imported_object_specialization.rs @@ -0,0 +1,261 @@ +//! Guarded specialization of stable imported object-literal methods (#8775). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; + +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_GC_VERIFY_EVACUATION", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +fn remove_gc_env_overrides(command: &mut Command) { + for key in GC_ENV_OVERRIDES { + command.env_remove(key); + } +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn fixture_dir() -> PathBuf { + workspace_root().join("test-files/fixtures/issue_8775_imported_object") +} + +fn target_debug_dir() -> PathBuf { + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")); + if cfg!(windows) { + target.join("x86_64-pc-windows-msvc").join("debug") + } else { + target.join("debug") + } +} + +fn assert_success(label: &str, output: &Output) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static"); + if cfg!(windows) { + command.arg("--target").arg("x86_64-pc-windows-msvc"); + } + let output = command.output().expect("build static runtime archive"); + assert_success("static runtime build", &output); + }); +} + +fn copy_fixture(dir: &Path) { + for file in [ + "package.json", + "adapter.js", + "barrel.js", + "main.js", + "semantics.js", + ] { + std::fs::copy(fixture_dir().join(file), dir.join(file)) + .unwrap_or_else(|error| panic!("copy {file}: {error}")); + } +} + +fn compile(dir: &Path, entry: &str, explain: bool) -> PathBuf { + ensure_runtime_archive(); + let binary = dir.join(format!("{entry}.bin")); + let mut command = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_perry"))); + command + .current_dir(dir) + .arg("compile") + .arg(entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .arg("--trace") + .arg("llvm") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", target_debug_dir()); + if explain { + command.arg("--opt-report=json").arg("--explain-lowering"); + } + remove_gc_env_overrides(&mut command); + let output = command.output().expect("run Perry compile"); + assert_success("Perry compile", &output); + binary +} + +fn run(binary: &Path, dir: &Path, moving_gc: bool) -> String { + let mut command = Command::new(binary); + command.current_dir(dir); + remove_gc_env_overrides(&mut command); + if moving_gc { + command + .env("PERRY_GC_SCAVENGE", "1") + .env("PERRY_GC_SCAVENGE_NURSERY_MB", "1") + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1") + .env("PERRY_GC_INCREMENTAL", "0"); + } + let output = command.output().expect("run compiled fixture"); + assert_success("compiled fixture", &output); + String::from_utf8(output.stdout).expect("fixture stdout is UTF-8") +} + +fn run_node(dir: &Path, entry: &str) -> String { + let output = Command::new("node") + .current_dir(dir) + .arg(entry) + .output() + .expect("run Node oracle"); + assert_success("Node oracle", &output); + String::from_utf8(output.stdout).expect("Node stdout is UTF-8") +} + +fn read_native_records(dir: &Path) -> Vec { + let lowering = dir.join(".perry-trace/lowering"); + let run_dir = std::fs::read_dir(&lowering) + .expect("read lowering directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| path.is_dir()) + .expect("lowering run directory"); + let mut records = Vec::new(); + for entry in std::fs::read_dir(run_dir).expect("read lowering run") { + let path = entry.expect("lowering entry").path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with("perry_native_reps_") || !name.ends_with(".json") { + continue; + } + let artifact: serde_json::Value = serde_json::from_slice( + &std::fs::read(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("parse {}: {error}", path.display())); + records.extend( + artifact["records"] + .as_array() + .unwrap_or_else(|| panic!("missing records in {}", path.display())) + .iter() + .cloned(), + ); + } + records +} + +fn record_notes(record: &serde_json::Value) -> Vec<&str> { + record["notes"] + .as_array() + .into_iter() + .flatten() + .filter_map(serde_json::Value::as_str) + .collect() +} + +#[test] +fn stable_imported_object_methods_use_guarded_direct_closure_bodies() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path()); + let binary = compile(temp.path(), "main.js", true); + + let node = run_node(temp.path(), "main.js"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); + assert_eq!(node.trim(), r#"{"checksum":400000,"remaining":0}"#); + + let main_ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/main_js.ll")) + .expect("read main LLVM IR"); + let adapter_ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/adapter_js.ll")) + .expect("read adapter LLVM IR"); + for func_id in [6, 7, 8, 9] { + let symbol = format!("perry_closure_adapter_js__{func_id}"); + assert!( + adapter_ir.contains(&format!("define double @{symbol}(")), + "producer closure must have external linkage: {symbol}" + ); + let fast_block = main_ir + .split("\n\n") + .find(|block| block.contains("imported_object.direct.") && block.contains(&symbol)) + .unwrap_or_else(|| panic!("no imported-object direct block for {symbol}:\n{main_ir}")); + assert!( + !fast_block.contains("js_native_call_method_by_id") + && !fast_block.contains("js_typed_feedback_native_call_method_by_id") + && !fast_block.contains("js_native_call_value"), + "direct block must not redispatch dynamically:\n{fast_block}" + ); + } + assert!(main_ir.contains("call double @js_native_call_method_by_id")); + assert!( + main_ir + .lines() + .any(|line| line.starts_with("@perry_global_adapter_js__") + && line.ends_with(" = external global double")), + "consumer must load the producer's exported object identity:\n{main_ir}" + ); + + let records = read_native_records(temp.path()); + let selected: Vec<_> = records + .iter() + .filter(|record| record["consumer"] == "imported_object_literal_method_direct_call") + .collect(); + assert!( + selected.len() >= 5, + "missing selected records: {records:#?}" + ); + for record in selected { + let notes = record_notes(record); + assert!(notes.contains(&"receiver_provenance=imported_object_literal_metadata")); + assert!(notes.contains(&"generic_dispatch_fallback=js_native_call_method_by_id")); + assert!( + notes.contains(&"guards=receiver_identity,exact_shape,own_data_slot,function_identity") + ); + } +} + +#[test] +fn mutations_rebinding_function_values_proxy_and_barrel_match_node() { + let temp = tempfile::tempdir().expect("tempdir"); + copy_fixture(temp.path()); + let binary = compile(temp.path(), "semantics.js", false); + let node = run_node(temp.path(), "semantics.js"); + assert_eq!(run(&binary, temp.path(), false), node); + assert_eq!(run(&binary, temp.path(), true), node); + + let ir = std::fs::read_to_string(temp.path().join(".perry-trace/llvm/semantics_js.ll")) + .expect("read semantics LLVM IR"); + assert!( + ir.contains("call double @perry_closure_adapter_js__7"), + "barrel import must retain producer method provenance:\n{ir}" + ); + assert!( + ir.contains("call double @js_native_call_method_by_id"), + "mutation-sensitive paths must retain the generic fallback:\n{ir}" + ); +} diff --git a/docs/src/native-libraries/abi.md b/docs/src/native-libraries/abi.md index a3a12ed3be..a9db81b191 100644 --- a/docs/src/native-libraries/abi.md +++ b/docs/src/native-libraries/abi.md @@ -102,6 +102,15 @@ return / parameter types — wrappers should write `pub extern "C" fn js_my_module_thing() -> *mut perry_ffi::StringHeader`, not import `StringHeader` from `perry-runtime` directly. +### Async promise rejection + +`JsPromise::reject_string(message)` copies the message and rejects with a real +JavaScript `Error` allocated on the runtime's main thread. Its `.message` and +`.stack` are available to ordinary handlers, and `instanceof Error` succeeds. +`JsPromise::reject(value)` remains the escape hatch for APIs that deliberately +reject with an arbitrary JavaScript value. Use `JsPromise::reject_with` to +construct a structured rejection value safely on the main thread. + ### What's NOT in v0.5 These will land as real wrappers force them, tracked under diff --git a/docs/src/native-libraries/authoring-guide.md b/docs/src/native-libraries/authoring-guide.md index d72f394dae..18bd0abe38 100644 --- a/docs/src/native-libraries/authoring-guide.md +++ b/docs/src/native-libraries/authoring-guide.md @@ -366,6 +366,12 @@ pub extern "C" fn js_my_fetch(url_ptr: *const StringHeader) -> *mut Promise { } ``` +`reject_string(message)` rejects with a real JavaScript `Error`: consumers can +use `error instanceof Error`, `error.message`, and `error.stack`. Use +`reject(value)` only when the API intentionally rejects with a non-Error value, +or `reject_with(...)` when the Error needs structured fields built on the main +thread. + ### Sync handle-based class Use a `handle` descriptor for synchronous resource-style APIs. The diff --git a/docs/src/native-libraries/overview.md b/docs/src/native-libraries/overview.md index cc56aad737..ad00c0a30e 100644 --- a/docs/src/native-libraries/overview.md +++ b/docs/src/native-libraries/overview.md @@ -184,7 +184,7 @@ The 9 surface dimensions perry-ffi exposes today are: | Surface | What it does | Documented at | |---|---|---| | Strings | `JsString` / `alloc_string` / `read_string` / `read_bytes` / `alloc_bytes` | [`abi.md`](abi.md) | -| Async / Promise | `JsPromise` (`new` / `resolve` / `reject_string`), `spawn_blocking` | [`abi.md`](abi.md) | +| Async / Promise | `JsPromise` (`new` / `resolve` / `reject_string` as `Error` / `reject_with`), `spawn_blocking` | [`abi.md`](abi.md) | | Handles | `register_handle` / `get_handle` / `with_handle` / `take_handle` / `iter_handles_of` | [`abi.md`](abi.md) | | JsValue + objects/arrays | `JsValue`, `js_array_alloc/push/get/set`, `js_object_alloc_with_shape`, `js_object_get_field`, `js_object_set_field`, `build_object_shape` | [`abi.md`](abi.md) | | Closures | `JsClosure::call0..4` | [`abi.md`](abi.md) | diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index a94d82f969..97901c90a0 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -15,6 +15,7 @@ "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/lower_call/new.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new_alloc.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, + "crates/perry-codegen/src/lower_call/property_get/imported_object.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/scalar_method.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs|8 + crate::target_layout::object_header_size_bytes( ) + 8 * slots;": 1, "crates/perry-codegen/src/stmt/loops.rs|let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, @@ -49,7 +50,7 @@ "crates/perry-runtime/src/object/object_ops.rs|keys_array|declaration|pub(crate) use keys_array::{": 1 }, "summary": { - "codegen_object_header_size_sites": 40, + "codegen_object_header_size_sites": 41, "raw_member_files": 7, "raw_member_sites": { "keys_array": 24 diff --git a/test-files/fixtures/issue_8772_short_packed_spread/main.ts b/test-files/fixtures/issue_8772_short_packed_spread/main.ts new file mode 100644 index 0000000000..df05760fea --- /dev/null +++ b/test-files/fixtures/issue_8772_short_packed_spread/main.ts @@ -0,0 +1,33 @@ +class Position { + x: number; + constructor() { this.x = 0; } + reset(entity: { id: number }, delta: number = 1): void { + this.x = entity.id + delta; + } +} + +class Velocity { + dx: number; + constructor() { this.dx = 0; } + reset(entity: { id: number }, delta: number = 2): void { + this.dx = entity.id + delta; + } +} + +const position = new Position(); +const velocity = new Velocity(); +const empty: number[] = []; +const one: number[] = [3]; + +function invoke(instance: any, entity: { id: number }, args: number[]): void { + instance.reset(entity, ...args); +} + +let checksum = 0; +for (let i = 0; i < 300000; i++) { + const entity = { id: i }; + invoke(position, entity, empty); + invoke(velocity, entity, one); + checksum += position.x + velocity.dx; +} +console.log(checksum); diff --git a/test-files/fixtures/issue_8772_short_packed_spread/semantics.ts b/test-files/fixtures/issue_8772_short_packed_spread/semantics.ts new file mode 100644 index 0000000000..63af0fd0a9 --- /dev/null +++ b/test-files/fixtures/issue_8772_short_packed_spread/semantics.ts @@ -0,0 +1,77 @@ +class Receiver { + run(prefix: string, value: any = "default"): string { + return prefix + "=" + String(value); + } +} + +class WideReceiver { + run(prefix: string, a: any, b: any, c: any, d: any, e: any): string { + return prefix + "=" + [a, b, c, d, e].join(","); + } +} + +class Tail extends Array {} + +function invoke(instance: any, prefix: string, args: any[]): string { + return instance.run(prefix, ...args); +} + +function invokeWide(instance: any, prefix: string, args: any[]): string { + return instance.run(prefix, ...args); +} + +const result: Record = {}; +const receiver = new Receiver(); + +result.empty = invoke(receiver, "empty", []); +result.one = invoke(receiver, "one", [1]); + +const hole = new Array(1); +result.hole = invoke(receiver, "hole", hole); + +const accessor: any[] = ["first", "discarded"]; +Object.defineProperty(accessor, "0", { + configurable: true, + get: function() { + accessor.length = 1; + return "getter"; + } +}); +result.accessorMutation = invoke(receiver, "accessor", accessor); + +const own: any[] = ["own-iterator"]; +own[Symbol.iterator] = own.values; +result.ownIterator = invoke(receiver, "own", own); + +const proxied: any = new Proxy(["proxy"], {}); +result.proxy = invoke(receiver, "proxy", proxied); + +const subclass = new Tail(); +subclass.push("subclass"); +result.subclass = invoke(receiver, "subclass", subclass); + +const mutated = ["old", "discarded"]; +mutated[0] = "new"; +mutated.length = 1; +result.elementAndLengthMutation = invoke(receiver, "mutated", mutated); + +const replaced: any = new Receiver(); +replaced.run = function(prefix: string, value: any): string { + return "replacement=" + prefix + ":" + value; +}; +result.replacedMethod = invoke(replaced, "method", [9]); + +const plain: any = { + run: function(prefix: string, value: any): string { + return "plain=" + prefix + ":" + value; + } +}; +result.wrongReceiver = invoke(plain, "receiver", [8]); + +result.oversized = invokeWide( + new WideReceiver(), + "wide", + [1, 2, 3, 4, 5] +); + +console.log(JSON.stringify(result)); diff --git a/test-files/fixtures/issue_8772_short_packed_spread/throwing.ts b/test-files/fixtures/issue_8772_short_packed_spread/throwing.ts new file mode 100644 index 0000000000..063c80af42 --- /dev/null +++ b/test-files/fixtures/issue_8772_short_packed_spread/throwing.ts @@ -0,0 +1,16 @@ +class Receiver { + run(value: any): string { + return String(value); + } +} + +function invoke(instance: any, args: any[]): string { + return instance.run(...args); +} + +const throwing: any = {}; +throwing[Symbol.iterator] = function(): any { + throw new Error("iterator-boom"); +}; + +invoke(new Receiver(), throwing); diff --git a/test-files/fixtures/issue_8775_imported_object/adapter.js b/test-files/fixtures/issue_8775_imported_object/adapter.js new file mode 100644 index 0000000000..543c078f60 --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/adapter.js @@ -0,0 +1,37 @@ +class Store { + constructor() { + this.entities = []; + } + + create(id) { + const entity = { id, value: 0 }; + this.entities.push(entity); + return entity; + } + + add(entity) { + entity.value += 1; + } + + destroy(entity) { + const index = this.entities.indexOf(entity); + if (index !== -1) this.entities.splice(index, 1); + return entity.value; + } +} + +export default { + store: null, + setup() { + this.store = new Store(); + }, + createEntity(id) { + return this.store.create(id); + }, + addComponent(entity) { + this.store.add(entity); + }, + destroyEntity(entity) { + return this.store.destroy(entity); + }, +}; diff --git a/test-files/fixtures/issue_8775_imported_object/barrel.js b/test-files/fixtures/issue_8775_imported_object/barrel.js new file mode 100644 index 0000000000..6a01ad8f1a --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/barrel.js @@ -0,0 +1 @@ +export { default } from "./adapter.js"; diff --git a/test-files/fixtures/issue_8775_imported_object/main.js b/test-files/fixtures/issue_8775_imported_object/main.js new file mode 100644 index 0000000000..66f62d231f --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/main.js @@ -0,0 +1,15 @@ +import adapter from "./adapter.js"; + +adapter.setup(); +let checksum = 0; +const iterations = 200_000; +for (let i = 0; i < iterations; i++) { + const entity = adapter.createEntity(i); + adapter.addComponent(entity); + adapter.addComponent(entity); + checksum += adapter.destroyEntity(entity); +} +console.log(JSON.stringify({ + checksum, + remaining: adapter.store.entities.length, +})); diff --git a/test-files/fixtures/issue_8775_imported_object/package.json b/test-files/fixtures/issue_8775_imported_object/package.json new file mode 100644 index 0000000000..089153bcb5 --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/package.json @@ -0,0 +1 @@ +{"type":"module"} diff --git a/test-files/fixtures/issue_8775_imported_object/semantics.js b/test-files/fixtures/issue_8775_imported_object/semantics.js new file mode 100644 index 0000000000..b518ac8b0c --- /dev/null +++ b/test-files/fixtures/issue_8775_imported_object/semantics.js @@ -0,0 +1,94 @@ +import adapter from "./barrel.js"; + +const out = {}; +out.keys = Object.keys(adapter).join(","); +const setupDescriptor = Object.getOwnPropertyDescriptor(adapter, "setup"); +out.descriptor = [ + setupDescriptor.enumerable, + setupDescriptor.writable, + setupDescriptor.configurable, +].join(","); +adapter.setup(); + +const stableAlias = adapter; +const stable = stableAlias.createEntity(5); +stableAlias.addComponent(stable); +out.stable = stableAlias.destroyEntity(stable); + +const originalCreate = adapter.createEntity; +adapter.createEntity = function (id) { + return { id: id + 100, components: 7 }; +}; +const replaced = adapter.createEntity(2); +out.replacement = replaced.id + replaced.components; +adapter.createEntity = originalCreate; + +const originalAdd = adapter.addComponent; +delete adapter.addComponent; +adapter.addComponent = function (entity) { + entity.components += 4; +}; +const recreated = { id: 3, components: 1 }; +adapter.addComponent(recreated); +out.deleteRecreate = recreated.components; +adapter.addComponent = originalAdd; + +const originalDestroy = adapter.destroyEntity; +Object.defineProperty(adapter, "destroyEntity", { + configurable: true, + get() { + return function (entity) { + return entity.id + 1000; + }; + }, +}); +out.accessor = adapter.destroyEntity({ id: 4, components: 0 }); +Object.defineProperty(adapter, "destroyEntity", { + configurable: true, + enumerable: true, + writable: true, + value: originalDestroy, +}); + +adapter.tag = 10; +adapter.functionValue = function (value) { + return this.tag + value; +}; +out.functionValue = adapter.functionValue(2); +const rebound = adapter.functionValue; +out.rebound = rebound.call({ tag: 30 }, 2); + +const extracted = adapter.createEntity; +out.extracted = extracted.call(adapter, 9).id; +const foreignReceiver = { + store: { + create(id) { + return { id: id + 700, components: 44 }; + }, + }, +}; +out.foreignReceiver = extracted.call(foreignReceiver, 10).id; + +const proxy = new Proxy(adapter, { + get(target, key) { + return target[key]; + }, +}); +out.proxy = proxy.createEntity(12).id; + +Object.setPrototypeOf(adapter, { + createEntity() { + return { id: -1, components: -1 }; + }, +}); +out.prototypeMutation = adapter.createEntity(13).id; + +let reassigned = adapter; +reassigned = { + createEntity(id) { + return { id: id + 500, components: 0 }; + }, +}; +out.reassignedAlias = reassigned.createEntity(1).id; + +console.log(JSON.stringify(out));