From e19daa47081786c64b514580460598921db947ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 13:40:59 +0200 Subject: [PATCH 01/15] perf(codegen): specialize branded ReadonlySet.has --- crates/perry-codegen/src/expr/mod.rs | 2 + .../src/expr/readonly_collection_tests.rs | 282 ++++++++++++++++++ .../src/lower_call/property_get/map_set.rs | 19 +- .../src/runtime_decls/strings.rs | 1 + crates/perry-codegen/src/type_analysis.rs | 2 +- .../src/type_analysis/strings.rs | 88 ++++++ crates/perry-runtime/src/set.rs | 48 ++- .../src/commands/compile/collect_modules.rs | 44 ++- .../src/commands/compile/run_pipeline.rs | 85 ++++-- .../tests/readonly_set_branded_dispatch.rs | 103 +++++++ .../tests/source_graph_export_regressions.rs | 41 +++ 11 files changed, 669 insertions(+), 46 deletions(-) create mode 100644 crates/perry-codegen/src/expr/readonly_collection_tests.rs create mode 100644 crates/perry/tests/readonly_set_branded_dispatch.rs diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 5cb84eb1b5..e5235a0ce5 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -169,6 +169,8 @@ mod call_spread_short; mod call_spread_short_tests; #[cfg(test)] mod issue7628_rooting_tests; +#[cfg(test)] +mod readonly_collection_tests; pub(crate) mod shadow_slot; #[cfg(test)] mod slice7_rooting_tests; diff --git a/crates/perry-codegen/src/expr/readonly_collection_tests.rs b/crates/perry-codegen/src/expr/readonly_collection_tests.rs new file mode 100644 index 0000000000..fcd8fff304 --- /dev/null +++ b/crates/perry-codegen/src/expr/readonly_collection_tests.rs @@ -0,0 +1,282 @@ +use crate::{compile_module, CompileOptions, ImportedClass}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, Param, Stmt}; + +fn number_param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn has_method() -> Function { + Function { + id: 2, + name: "hasComponent".to_string(), + type_params: Vec::new(), + params: vec![number_param(1, "componentType")], + return_type: Type::Boolean, + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "componentTypeSet".to_string(), + byte_offset: 0, + }), + property: "has".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(1)], + type_args: Vec::new(), + byte_offset: 0, + }))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn archetype_class() -> Class { + Class { + id: 1, + name: "Archetype".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ClassField { + name: "componentTypeSet".to_string(), + key_expr: None, + ty: Type::Generic { + base: "ReadonlySet".to_string(), + type_args: vec![Type::Number], + }, + init: None, + is_private: false, + is_readonly: true, + decorators: Vec::new(), + }], + constructor: None, + methods: vec![has_method()], + 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 executor_class() -> Class { + Class { + id: 3, + name: "CommandExecutor".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![Function { + id: 4, + name: "contains".to_string(), + type_params: Vec::new(), + params: vec![ + Param { + id: 1, + name: "archetype".to_string(), + ty: Type::Union(vec![Type::Named("Archetype".to_string()), Type::Void]), + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + number_param(2, "componentType"), + ], + return_type: Type::Boolean, + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "componentTypeSet".to_string(), + byte_offset: 0, + }), + property: "has".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(2)], + type_args: Vec::new(), + byte_offset: 0, + }))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + 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 compile_has_ir() -> String { + let mut module = Module::new("readonly_set_field.ts"); + module.classes.push(archetype_class()); + module.classes.push(executor_class()); + String::from_utf8( + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ) + .expect("ReadonlySet field call compiles"), + ) + .expect("LLVM IR is UTF-8") +} + +fn imported_archetype() -> ImportedClass { + ImportedClass { + name: "Archetype".to_string(), + local_alias: None, + source_prefix: "archetype_ts".to_string(), + constructor_param_count: 0, + has_own_constructor: true, + constructor_has_rest: false, + has_instance_fields: true, + 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: vec!["componentTypeSet".to_string()], + field_types: vec![Type::Generic { + base: "ReadonlySet".to_string(), + type_args: vec![Type::Number], + }], + source_class_id: Some(1), + return_shape_imports: Vec::new(), + object_literal: None, + } +} + +fn compile_imported_has_ir() -> String { + let mut module = Module::new("command_executor.ts"); + module.classes.push(executor_class()); + let mut options = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + options.imported_classes.push(imported_archetype()); + String::from_utf8( + compile_module(&module, options).expect("imported ReadonlySet field compiles"), + ) + .expect("LLVM IR is UTF-8") +} + +fn method_ir<'a>(ir: &'a str, owner: &str, method: &str) -> &'a str { + let suffix = format!("__{owner}__{method}("); + let suffix_start = ir.find(&suffix).expect("requested method is present"); + let start = ir[..suffix_start] + .rfind("define double @perry_method_") + .expect("requested method has a definition"); + let method_and_rest = &ir[start..]; + let end = method_and_rest + .find("\n}\n") + .expect("requested method has a closing brace"); + &method_and_rest[..end + 3] +} + +#[test] +fn readonly_set_field_has_uses_branded_collection_fast_path() { + let ir = compile_has_ir(); + let method_ir = method_ir(&ir, "Archetype", "hasComponent"); + + assert!( + method_ir.contains("call double @js_readonly_set_has("), + "ReadonlySet.has must use the branded native-Set fast path with a structural-object fallback:\n{method_ir}" + ); + assert!( + !method_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "the common native-Set case must not enter the full generic dispatch tower:\n{method_ir}" + ); +} + +#[test] +fn nullable_class_receiver_readonly_set_field_uses_branded_fast_path() { + let ir = compile_has_ir(); + let method_ir = method_ir(&ir, "CommandExecutor", "contains"); + + assert!( + method_ir.contains("call double @js_readonly_set_has("), + "a ReadonlySet field reached through `Archetype | undefined` must retain the branded candidate:\n{method_ir}" + ); + assert!( + !method_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "the nullable owner type must not force every native Set through generic method dispatch:\n{method_ir}" + ); +} + +#[test] +fn imported_class_readonly_set_field_uses_branded_fast_path() { + let ir = compile_imported_has_ir(); + let method_ir = method_ir(&ir, "CommandExecutor", "contains"); + + assert!( + method_ir.contains("call double @js_readonly_set_has("), + "an imported class's published ReadonlySet field type must remain a branded candidate:\n{method_ir}" + ); + assert!( + !method_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "cross-module field metadata must not force native Sets through generic dispatch:\n{method_ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/property_get/map_set.rs b/crates/perry-codegen/src/lower_call/property_get/map_set.rs index 55d422ef60..6d90dee044 100644 --- a/crates/perry-codegen/src/lower_call/property_get/map_set.rs +++ b/crates/perry-codegen/src/lower_call/property_get/map_set.rs @@ -29,7 +29,9 @@ use perry_hir::Expr; use crate::expr::{lower_expr, unbox_to_i64, FnCtx}; use crate::nanbox::double_literal; use crate::rooting; -use crate::type_analysis::{is_map_expr, is_set_expr, is_url_search_params_expr}; +use crate::type_analysis::{ + is_map_expr, is_readonly_set_expr, is_set_expr, is_url_search_params_expr, +}; use crate::types::{DOUBLE, I64}; /// Map/Set methods on PropertyGet receivers. The HIR only folds @@ -42,6 +44,21 @@ pub(crate) fn try_lower_map_set_methods( property: &str, args: &[Expr], ) -> Result> { + // `ReadonlySet` is a structural interface, not a native-layout proof. + // The runtime helper brand-checks the overwhelmingly common genuine Set + // and otherwise preserves JavaScript dispatch (custom interface objects, + // proxies, and Set subclasses with overrides). + if is_readonly_set_expr(ctx, object) && property == "has" && args.len() == 1 { + return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { + let receiver = vals[0].clone(); + let value = vals[1].clone(); + Ok(Some(ctx.block().call( + DOUBLE, + "js_readonly_set_has", + &[(DOUBLE, &receiver), (DOUBLE, &value)], + ))) + }); + } if is_map_expr(ctx, object) { match property { "set" if args.len() == 2 => { diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 95b887d5ec..2a90e2db99 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -634,6 +634,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_set_add_f32", I64, &[I64, F32]); module.declare_function("js_set_add_bool", I64, &[I64, I32]); module.declare_function("js_set_has", I32, &[I64, DOUBLE]); + module.declare_function("js_readonly_set_has", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_set_has_string", I32, &[I64, I64]); module.declare_function("js_set_has_number", I32, &[I64, DOUBLE]); module.declare_function("js_set_has_i32", I32, &[I64, I32]); diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index 01d70ace64..2212a195c2 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -60,7 +60,7 @@ pub(crate) use refine::{ }; pub(crate) use strings::{ class_name_extends_url_search_params, is_declared_string_expr, is_definitely_string_expr, - is_map_expr, is_set_expr, is_string_expr, is_url_search_params_expr, + is_map_expr, is_readonly_set_expr, is_set_expr, is_string_expr, is_url_search_params_expr, is_url_search_params_subclass_expr, map_static_type_args, set_static_type_args, string_proof_is_declared_only, string_value_is_runtime_guaranteed, }; diff --git a/crates/perry-codegen/src/type_analysis/strings.rs b/crates/perry-codegen/src/type_analysis/strings.rs index 70c21642cc..13231d7db8 100644 --- a/crates/perry-codegen/src/type_analysis/strings.rs +++ b/crates/perry-codegen/src/type_analysis/strings.rs @@ -38,6 +38,94 @@ pub(crate) fn is_set_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } +/// True when the declared receiver type is TypeScript's structural +/// `ReadonlySet` interface. +/// +/// This is deliberately separate from [`is_set_expr`]. A `ReadonlySet` +/// annotation does not prove that the value has Perry's native `SetHeader` +/// layout: an ordinary object can implement the interface. Callers must use a +/// runtime-branded operation with a normal method-dispatch fallback. +pub(crate) fn is_readonly_set_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { + match e { + // Unlike native-layout lowering, this is only a guarded candidate: + // the runtime helper preserves generic dispatch on a brand miss. A + // declared hint therefore remains useful even for reassigned locals. + Expr::LocalGet(id) => ctx.local_type_hint(id).is_some_and(type_is_readonly_set), + Expr::PropertyGet { + object, property, .. + } => static_type_of(ctx, object).is_some_and(|owner_ty| { + type_may_declare_readonly_set_field(ctx, &owner_ty, property, 0) + }), + _ => false, + } +} + +#[inline] +fn type_is_readonly_set(ty: &HirType) -> bool { + matches!(ty, HirType::Generic { base, .. } if base == "ReadonlySet") +} + +/// Look through a nullable/union owner claim for a field declared as +/// `ReadonlySet`. This is a dispatch candidate, not a layout proof: a false +/// positive only reaches `js_readonly_set_has`, whose brand miss performs the +/// original method call. That lets `Archetype | undefined` retain the useful +/// candidate without weakening nullish, proxy, subclass, or structural-object +/// semantics. +fn type_may_declare_readonly_set_field( + ctx: &FnCtx<'_>, + owner_ty: &HirType, + property: &str, + depth: usize, +) -> bool { + if depth > 32 { + return false; + } + match owner_ty { + HirType::Union(variants) => variants.iter().any(|variant| { + !matches!(variant, HirType::Null | HirType::Void | HirType::Never) + && type_may_declare_readonly_set_field(ctx, variant, property, depth + 1) + }), + HirType::Named(name) | HirType::Generic { base: name, .. } => { + if let Some(class) = ctx.classes.get(name) { + if let Some(field) = class.fields.iter().find(|field| field.name == property) { + return type_is_readonly_set(&field.ty); + } + if let Some(parent) = class.extends_name.as_deref() { + return type_may_declare_readonly_set_field( + ctx, + &HirType::Named(parent.to_string()), + property, + depth + 1, + ); + } + } + if let Some(iface) = ctx.interfaces.get(name) { + if let Some(field) = iface.properties.iter().find(|field| field.name == property) { + return type_is_readonly_set(&field.ty); + } + if iface.extends.iter().any(|parent| { + type_may_declare_readonly_set_field(ctx, parent, property, depth + 1) + }) { + return true; + } + } + matches!( + ctx.type_aliases.get(name), + Some(HirType::Object(object)) + if object + .properties + .get(property) + .is_some_and(|field| type_is_readonly_set(&field.ty)) + ) + } + HirType::Object(object) => object + .properties + .get(property) + .is_some_and(|field| type_is_readonly_set(&field.ty)), + _ => false, + } +} + pub(crate) fn set_static_type_args<'a>(ctx: &'a FnCtx<'_>, e: &Expr) -> Option<&'a [HirType]> { match e { Expr::LocalGet(id) diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index f83afc1169..4037651a75 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -1115,14 +1115,52 @@ pub extern "C" fn js_set_has(set: *const SetHeader, value: f64) -> i32 { if set.is_null() { return 0; } + set_has_resolved(set, value) +} + +#[inline(always)] +fn set_has_resolved(set: *const SetHeader, value: f64) -> i32 { let value = normalize_zero(value); - unsafe { - if find_value_index(set, value) >= 0 { - 1 - } else { - 0 + unsafe { i32::from(find_value_index(set, value) >= 0) } +} + +/// Fast `ReadonlySet.has` that preserves TypeScript's structural semantics. +/// +/// A `ReadonlySet` annotation is not a native-layout guarantee: ordinary +/// objects, proxies, and Set subclasses can all inhabit it. A genuine +/// `GC_TYPE_SET` receiver takes the direct lookup without the generic method +/// tower. Every other receiver retains normal JavaScript `receiver.has(value)` +/// dispatch, including user overrides and the usual TypeError behavior. +#[no_mangle] +pub unsafe extern "C-unwind" fn js_readonly_set_has(receiver: f64, value: f64) -> f64 { + let receiver_value = crate::value::JSValue::from_bits(receiver.to_bits()); + if receiver_value.is_pointer() { + let raw = receiver_value.as_pointer::(); + if matches!( + crate::value::addr_class::try_read_gc_header(raw as usize), + Some(header) if header.obj_type == crate::gc::GC_TYPE_SET + ) { + return f64::from_bits( + crate::value::JSValue::bool(set_has_resolved(raw, value) != 0).bits(), + ); } } + + // The structural fallback can allocate and re-enter generated code. Root + // both operands before crossing that boundary, then pass refreshed values + // into the existing dispatcher (which establishes its own roots before + // the first collecting probe). + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_handle = scope.root_nanbox_f64(receiver); + let value_handle = scope.root_nanbox_f64(value); + let refreshed_value = value_handle.get_nanbox_f64(); + crate::object::js_native_call_method( + receiver_handle.get_nanbox_f64(), + b"has".as_ptr() as *const i8, + 3, + &refreshed_value, + 1, + ) } #[no_mangle] diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index ddaeaab8f9..aabfdf1b1e 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1011,18 +1011,40 @@ fn collect_module_one( // Process imports and update their resolved paths and module kinds for import in &mut hir_module.imports { - // Skip type-only imports — they were recorded for class-metadata flow - // (see lower.rs's #446 comment: a per-specifier `import { type Foo }` - // is preserved so Foo's class info reaches `imported_classes` for - // method dispatch) but they MUST NOT be loaded as runtime modules. - // Without this skip, `import type { StandardSchemaV1 } from - // "@standard-schema/spec"` (Effect's only `@standard-schema` use, - // a type-only reference) queued the package's V8 fallback. The - // spec ships an empty `src_exports = {}` at runtime, so any - // `something._tag` from the import binding then threw - // `TypeError: Cannot read properties of undefined (reading '_tag')` - // during Effect's module init. Refs #321, #684. + // Resolve TypeScript type-only imports for metadata, but never queue + // their target as a runtime module. The final graph may already + // contain the target through a value import elsewhere; retaining its + // canonical path here then lets run_pipeline attach that existing + // class's field/method metadata to this consumer. This is compile-time + // bookkeeping only: no init edge, binding, package capability, or V8 + // fallback is created. In particular, the `@standard-schema/spec` + // case from #684 remains erased at runtime. if import.type_only { + if let Some(alias) = ctx.package_aliases.get(import.source.as_str()).cloned() { + import.source = alias; + import.is_native = perry_hir::is_native_module(&import.source); + } + if !import.is_native { + if let Some(resolved) = cached_resolve_import_with_lexical_base( + &import.source, + entry_path, + &canonical, + ctx, + ) { + let resolved_path = resolved.canonical_path; + let kind = if resolved.kind == ModuleKind::Interpreted + && !is_in_perry_native_package(&resolved_path) + && !is_declaration_file(&resolved_path) + && aot_promotion_is_authorized(&resolved_path, ctx) + { + ModuleKind::NativeCompiled + } else { + resolved.kind + }; + import.resolved_path = Some(resolved_path.to_string_lossy().to_string()); + import.module_kind = kind; + } + } continue; } let uses_file_loader = file_loader_sources.contains(&import.source); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 2d26c29371..fbaf832649 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -3095,34 +3095,6 @@ pub fn run_with_parse_cache( if import.module_kind != perry_hir::ModuleKind::NativeCompiled { continue; } - // Issue #684: skip WHOLE-DECL type-only imports - // (`import type * as X`, `import type { Foo }`). They - // contribute zero runtime state — neither the namespace - // binding nor the named members ever appear in a - // value-position expression after type erasure. Pre-fix - // the loop below treated them like value imports and - // registered every export of the source module into - // `import_function_prefixes` / `namespace_member_prefixes`, - // which collided with later named-import registrations: - // effect's `ParseResult.ts` has both - // `import { TaggedError } from "./Data.js"` - // `import type * as Schema from "./Schema.js"` - // Schema.ts also exports `TaggedError`, so the type-only - // loop iteration registered `TaggedError → Schema_ts` - // into `import_function_prefixes`. If Schema.ts was - // processed AFTER Data.ts (HashMap iteration order is - // unstable), the Schema entry won — and top-level - // `class ParseError extends TaggedError("ParseError")` - // dispatched into Schema.ts's `TaggedError` instead of - // Data.ts's. Worse, Schema.ts is type-only so it isn't - // in `module_init_deps` either, meaning its backing - // global was still 0.0 — `js_closure_call1(0.0, ...)` - // threw `TypeError: value is not a function` during - // `ParseResult.ts__init`. Closes #684 (companion to - // #680's `module_init_deps` filter at L3234). - if import.type_only { - continue; - } let resolved_path = match &import.resolved_path { Some(p) => p, None => continue, @@ -3137,6 +3109,63 @@ pub fn run_with_parse_cache( Some(m) => sanitize_name(&m.name), None => continue, }; + // A whole-declaration `import type` contributes no runtime + // binding or init edge (#684), but a named class annotation + // may still carry useful producer-authored field metadata. + // Attach only that exact class when its defining module is + // already present in the value-reachable graph. Do not touch + // function/namespace maps, imported vars, native libraries, + // or module-init dependencies: those were the collision and + // phantom-load hazards #684 removed. + if import.type_only { + for spec in &import.specifiers { + let perry_hir::ImportSpecifier::Named { imported, local } = spec else { + continue; + }; + let key = (resolved_path_str.clone(), imported.clone()); + let Some(class) = exported_classes.get(&key) else { + continue; + }; + let origin_path = all_module_exports + .get(&resolved_path_str) + .and_then(|exports| exports.get(imported)) + .cloned() + .unwrap_or_else(|| resolved_path_str.clone()); + let effective_prefix = if origin_path != resolved_path_str { + compute_module_prefix(&origin_path, &ctx.project_root) + } else { + source_prefix.clone() + }; + let class_prefix = canonical_class_source_prefix( + class, + &class_canonical_path, + &ctx.project_root, + &effective_prefix, + ); + let local_alias = (local != &class.name).then(|| local.clone()); + let duplicate = imported_classes.iter().any(|existing| { + existing.name == class.name + && existing.local_alias.as_ref() == local_alias.as_ref() + && existing.source_prefix == class_prefix + }); + if !duplicate { + imported_classes.push(imported_class_from_hir( + class, + class_prefix, + local_alias, + proven_this_methods_for_import( + class, + &class_proven_this_methods, + ), + proven_this_methods_for_import( + class, + &class_proven_this_tower_methods, + ), + )); + } + } + continue; + } // PerryTS/storekit#1: when the import source is a package that // declares `perry.nativeLibrary` (e.g. `@perryts/storekit`), // its `.ts` source is a wrapper holding ambient `export diff --git a/crates/perry/tests/readonly_set_branded_dispatch.rs b/crates/perry/tests/readonly_set_branded_dispatch.rs new file mode 100644 index 0000000000..5fa1d9cf9d --- /dev/null +++ b/crates/perry/tests/readonly_set_branded_dispatch.rs @@ -0,0 +1,103 @@ +//! Executable semantics for the `ReadonlySet.has` branded fast path. +//! Native Sets bypass generic dispatch, while TypeScript's structural values +//! and Set subclasses retain ordinary JavaScript method lookup. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn native_structural_and_subclass_receivers_keep_has_semantics() { + let stdout = compile_and_run( + r#" +class Holder { + constructor(public readonly values: ReadonlySet) {} + contains(value: number): boolean { + return this.values.has(value); + } +} + +function nullableContains(holder: Holder | undefined, value: number): boolean { + return holder.values.has(value); +} + +const native = new Holder(new Set([2, 4, 6])); +console.log("native", native.contains(4), native.contains(5)); + +let customCalls = 0; +const structural = { + has(value: number) { + customCalls++; + return value === 7; + }, +} as unknown as ReadonlySet; +const custom = new Holder(structural); +console.log("structural", custom.contains(7), custom.contains(8), customCalls); +console.log("nullable", nullableContains(custom, 7), customCalls); + +let nullishRejected = false; +try { + nullableContains(undefined, 7); +} catch (_error) { + nullishRejected = true; +} +console.log("nullish", nullishRejected); + +class OddSet extends Set { + override has(value: number): boolean { + return value === 99; + } +} +const subclass = new Holder(new OddSet([1, 3])); +console.log("subclass", subclass.contains(99), subclass.contains(1)); +"#, + ); + + assert_eq!( + stdout, + "native true false\n\ + structural true false 2\n\ + nullable true 3\n\ + nullish true\n\ + subclass true false\n" + ); +} diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index 9a5042147e..7338da391c 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -473,6 +473,47 @@ fn whole_type_only_import_does_not_wrap_same_named_runtime_builtin() { assert_eq!(compile_and_run(dir.path(), "main.ts"), "true\n"); } +#[test] +fn type_only_class_import_retains_guarded_readonly_set_field_metadata() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "archetype.ts", + "export class Archetype {\n\ + readonly componentTypeSet: ReadonlySet;\n\ + constructor(values: number[]) { this.componentTypeSet = new Set(values); }\n\ + }\n", + ); + write( + dir.path(), + "factory.ts", + "import { Archetype } from './archetype';\n\ + export function makeArchetype() { return new Archetype([3, 5]); }\n", + ); + write( + dir.path(), + "main.ts", + "import type { Archetype } from './archetype';\n\ + import { makeArchetype } from './factory';\n\ + function contains(archetype: Archetype, value: number) {\n\ + return archetype.componentTypeSet.has(value);\n\ + }\n\ + const archetype = makeArchetype();\n\ + console.log(contains(archetype, 3), contains(archetype, 4));\n", + ); + + let (stdout, entry_ir) = compile_and_run_with_llvm_trace(dir.path(), "main.ts"); + assert_eq!(stdout, "true false\n"); + assert!( + entry_ir.contains("call double @js_readonly_set_has("), + "type-only class imports must retain field metadata for guarded collection dispatch:\n{entry_ir}" + ); + assert!( + !entry_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "the imported ReadonlySet field should not fall through generic native dispatch:\n{entry_ir}" + ); +} + #[test] fn type_only_interface_dispatch_uses_runtime_class_registry() { let dir = tempfile::tempdir().expect("tempdir"); From 9c52782bd342c27ddda90820e410264905109b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 15:05:00 +0200 Subject: [PATCH 02/15] chore: add changelog for #8826 --- changelog.d/8826-readonly-set-has.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/8826-readonly-set-has.md diff --git a/changelog.d/8826-readonly-set-has.md b/changelog.d/8826-readonly-set-has.md new file mode 100644 index 0000000000..0514a1bc1f --- /dev/null +++ b/changelog.d/8826-readonly-set-has.md @@ -0,0 +1,4 @@ +Speed up calls to `ReadonlySet.has` with an exact runtime Set-brand check and +ordinary method dispatch fallback. Type-only class imports now retain the +field metadata needed for this guarded optimization without creating runtime +module bindings or initialization edges. From c1a2287cb487c4dfafdd92134a0f3c44c2425335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 15:37:00 +0200 Subject: [PATCH 03/15] chore(codegen): classify readonly Set type hint --- scripts/local_binding_type_allowlist.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index a78f912533..c3ff53bcad 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -513,6 +513,14 @@ "classification": "representation-proven", "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_readonly_set_expr", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The declared ReadonlySet hint only nominates js_readonly_set_has; that helper admits direct layout access after an exact live Set brand check and otherwise performs the original method dispatch." + }, { "path": "crates/perry-codegen/src/type_analysis/strings.rs", "function": "is_set_expr", From 92cfb4f5b3a15f8dcd56539c10fd71739feda59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 13:53:12 +0200 Subject: [PATCH 04/15] fix(async_hooks): address lifecycle review feedback --- changelog.d/8815-async-hooks-lifecycle.md | 3 + .../perry-codegen/src/expr/this_super_call.rs | 25 +- .../perry-codegen/src/lower_call/builtin.rs | 11 +- crates/perry-ext-events/src/lib.rs | 60 ++-- crates/perry-ext-http/src/lib.rs | 6 +- .../src/server/handle_dispatch.rs | 10 - crates/perry-ext-net/src/lib.rs | 6 +- crates/perry-ext-net/src/lifecycle.rs | 35 +- crates/perry-ext-zlib/src/stream.rs | 340 +++++++++++------- crates/perry-runtime/src/async_hooks.rs | 331 +++++++++++------ .../src/async_hooks/provider_ffi.rs | 152 ++++++-- .../src/async_hooks/test_support.rs | 47 +++ .../runtime_roots/hook_dispatch_handles.rs | 38 ++ crates/perry-stdlib/src/webcrypto/digest.rs | 4 +- .../src/worker_threads/worker_pump.rs | 90 ++--- crates/perry-stdlib/src/zlib.rs | 245 ++++++++----- scripts/thread_local_cold_allowlist.json | 2 +- .../integrations/events-emitter.ts | 15 + .../resource/shadowed-spread-parent.ts | 19 + 19 files changed, 986 insertions(+), 453 deletions(-) create mode 100644 changelog.d/8815-async-hooks-lifecycle.md create mode 100644 test-parity/node-suite/async_hooks/resource/shadowed-spread-parent.ts diff --git a/changelog.d/8815-async-hooks-lifecycle.md b/changelog.d/8815-async-hooks-lifecycle.md new file mode 100644 index 0000000000..1f1b1c9a50 --- /dev/null +++ b/changelog.d/8815-async-hooks-lifecycle.md @@ -0,0 +1,3 @@ +### Fixed + +- Completed Node `async_hooks` lifecycle support across async resources, event emitters, HTTP, sockets, workers, zlib, DNS, and WebCrypto. Provider scopes now restore execution and `AsyncLocalStorage` state when hooks or callbacks throw, deferred destroy hooks run at the correct lifecycle boundary, and allocation-sensitive values remain rooted across moving garbage collections. diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index ad8b47a3a8..48fdc8bdd9 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -261,7 +261,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let async_parent = ctx .classes .get(¤t_class_name) - .and_then(|class| class.extends_name.clone()); + .filter(|class| class.extends_expr.is_none() && !class.heritage_lexically_shadowed) + .and_then(|class| class.extends_name.clone()) + .filter(|parent| !ctx.classes.contains_key(parent.as_str())); if matches!( async_parent.as_deref(), Some("EventEmitterAsyncResource" | "AsyncLocalStorage" | "AsyncResource") @@ -269,15 +271,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); let zero_idx = "0".to_string(); let one_idx = "1".to_string(); - let first = - ctx.block() - .call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &zero_idx)]); - let second = - ctx.block() - .call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &one_idx)]); - rooting::with_rooted_group(ctx, 3, |ctx, group| { + rooting::with_rooted_group(ctx, 4, |ctx, group| { let this_root = group.adopt_emitted(ctx, Repr::Boxed, &this_box, true); + let arr_root = group.adopt_emitted(ctx, Repr::Ptr, &arr, true); + let arr = group.reread_emitted(ctx, arr_root); + let first = ctx.block().call( + DOUBLE, + "js_array_get_f64", + &[(I64, &arr), (I32, &zero_idx)], + ); let first_root = group.adopt_emitted(ctx, Repr::Boxed, &first, true); + let arr = group.reread_emitted(ctx, arr_root); + let second = ctx.block().call( + DOUBLE, + "js_array_get_f64", + &[(I64, &arr), (I32, &one_idx)], + ); let second_root = group.adopt_emitted(ctx, Repr::Boxed, &second, true); let this_box = group.reread_emitted(ctx, this_root); match async_parent.as_deref() { diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 6e3e29f1ba..41c766ed3f 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -153,6 +153,7 @@ pub(super) fn lower_builtin_new<'a>( Some(index) => group.reread(ctx, index)?, None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; + let options = group.adopt_emitted(ctx, crate::rooting::Repr::Boxed, &options, true); let runtime = if import_src.is_some_and(|source| { source.strip_prefix("node:").unwrap_or(source) == "dns/promises" }) { @@ -163,12 +164,10 @@ pub(super) fn lower_builtin_new<'a>( ctx.pending_declares .push((runtime.to_string(), DOUBLE, vec![I64])); let zero = "0".to_string(); - let args_array = ctx.block().call(I64, "js_array_alloc", &[(I32, &zero)]); - let args_array = ctx.block().call( - I64, - "js_array_push_f64", - &[(I64, &args_array), (DOUBLE, &options)], - ); + let args_array = group.begin_array(ctx, &zero); + let options = group.reread_emitted(ctx, options); + group.push_array(ctx, args_array, &options); + let args_array = group.read_array(ctx, args_array); Ok(Some(ctx.block().call( DOUBLE, runtime, diff --git a/crates/perry-ext-events/src/lib.rs b/crates/perry-ext-events/src/lib.rs index cfeb124c79..169a35c312 100644 --- a/crates/perry-ext-events/src/lib.rs +++ b/crates/perry-ext-events/src/lib.rs @@ -23,7 +23,7 @@ use perry_ffi::{ error_value_with_code, js_array_alloc, js_array_get, js_array_length, js_array_push, js_array_set, js_object_alloc_with_shape, js_object_set_field, nanbox_string_bits, read_string, throw_with_code, ArrayHeader, ErrorKind, Handle, JsPromise, JsString, JsValue, ObjectHeader, - Promise, RawClosureHeader, StringHeader, TransientRootScope, + Promise, RawClosureHeader, StringHeader, TransientRootScope, TransientRootedAddr, }; use std::collections::{HashMap, HashSet}; use std::ffi::c_void; @@ -1296,16 +1296,18 @@ pub unsafe extern "C" fn js_event_emitter_emit( event_bits: i64, args_ptr: *mut ArrayHeader, ) -> f64 { - if event_name_from_bits(event_bits).is_none() { + let roots = TransientRootScope::enter(); + let args_ptr = roots.root_addr(args_ptr as i64); + let Some(event_name) = event_name_from_bits(event_bits) else { return f64::from_bits(0x7FFC_0000_0000_0003); - } + }; let async_id = event_emitter_async_id(handle); if async_id == 0 { - return js_event_emitter_emit_impl(handle, event_bits, args_ptr); + return js_event_emitter_emit_impl(handle, &event_name, args_ptr.get() as *mut ArrayHeader); } let mut call = EventEmitterEmitCall { handle, - event_bits, + event_name, args_ptr, }; js_async_hooks_provider_run_catching( @@ -1317,40 +1319,41 @@ pub unsafe extern "C" fn js_event_emitter_emit( struct EventEmitterEmitCall { handle: Handle, - event_bits: i64, - args_ptr: *mut ArrayHeader, + event_name: String, + args_ptr: TransientRootedAddr, } unsafe extern "C" fn event_emitter_emit_thunk(data: *mut std::ffi::c_void) -> f64 { let call = &mut *(data as *mut EventEmitterEmitCall); - js_event_emitter_emit_impl(call.handle, call.event_bits, call.args_ptr) + js_event_emitter_emit_impl( + call.handle, + &call.event_name, + call.args_ptr.get() as *mut ArrayHeader, + ) } unsafe fn js_event_emitter_emit_impl( handle: Handle, - event_bits: i64, + event_name: &str, args_ptr: *mut ArrayHeader, ) -> f64 { const TAG_FALSE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0003); const TAG_TRUE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0004); - let Some(event_name) = event_name_from_bits(event_bits) else { - return TAG_FALSE_F64; - }; let mut had_listeners = false; let mut domain_error: Option<(Handle, f64)> = None; let mut throw_error: Option = None; if let Some(emitter) = get_event_emitter_mut(handle) { - let snapshot: Vec = match emitter.events.get(&event_name) { + let snapshot: Vec = match emitter.events.get(event_name) { Some(v) if !v.is_empty() => v.clone(), _ => Vec::new(), }; if !snapshot.is_empty() { had_listeners = true; if snapshot.iter().any(|l| l.once) { - if let Some(v) = emitter.events.get_mut(&event_name) { + if let Some(v) = emitter.events.get_mut(event_name) { v.retain(|l| !l.once); } - emitter.prune_event_if_empty(&event_name); + emitter.prune_event_if_empty(event_name); } } @@ -1374,7 +1377,7 @@ unsafe fn js_event_emitter_emit_impl( } if domain_error.is_none() && throw_error.is_none() { - drain_pending_once_promises(emitter, &event_name, args_ptr); + drain_pending_once_promises(emitter, event_name, args_ptr); let capture_rejections = emitter.capture_rejections && event_name != "error"; for l in snapshot { @@ -1412,14 +1415,14 @@ unsafe fn js_event_emitter_emit_impl( /// `event_name_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) -> f64 { - if event_name_from_bits(event_bits).is_none() { + let Some(event_name) = event_name_from_bits(event_bits) else { return f64::from_bits(0x7FFC_0000_0000_0003); - } + }; let async_id = event_emitter_async_id(handle); if async_id == 0 { - return js_event_emitter_emit0_impl(handle, event_bits); + return js_event_emitter_emit0_impl(handle, &event_name); } - let mut call = EventEmitterEmit0Call { handle, event_bits }; + let mut call = EventEmitterEmit0Call { handle, event_name }; js_async_hooks_provider_run_catching( async_id, event_emitter_emit0_thunk, @@ -1429,35 +1432,32 @@ pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) struct EventEmitterEmit0Call { handle: Handle, - event_bits: i64, + event_name: String, } unsafe extern "C" fn event_emitter_emit0_thunk(data: *mut std::ffi::c_void) -> f64 { let call = &mut *(data as *mut EventEmitterEmit0Call); - js_event_emitter_emit0_impl(call.handle, call.event_bits) + js_event_emitter_emit0_impl(call.handle, &call.event_name) } -unsafe fn js_event_emitter_emit0_impl(handle: Handle, event_bits: i64) -> f64 { +unsafe fn js_event_emitter_emit0_impl(handle: Handle, event_name: &str) -> f64 { const TAG_FALSE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0003); const TAG_TRUE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0004); - let Some(event_name) = event_name_from_bits(event_bits) else { - return TAG_FALSE_F64; - }; let mut had_listeners = false; let mut domain_error: Option<(Handle, f64)> = None; let mut throw_error: Option = None; if let Some(emitter) = get_event_emitter_mut(handle) { - let snapshot: Vec = match emitter.events.get(&event_name) { + let snapshot: Vec = match emitter.events.get(event_name) { Some(v) if !v.is_empty() => v.clone(), _ => Vec::new(), }; if !snapshot.is_empty() { had_listeners = true; if snapshot.iter().any(|l| l.once) { - if let Some(v) = emitter.events.get_mut(&event_name) { + if let Some(v) = emitter.events.get_mut(event_name) { v.retain(|l| !l.once); } - emitter.prune_event_if_empty(&event_name); + emitter.prune_event_if_empty(event_name); } } @@ -1480,7 +1480,7 @@ unsafe fn js_event_emitter_emit0_impl(handle: Handle, event_bits: i64) -> f64 { } } if domain_error.is_none() && throw_error.is_none() { - drain_pending_once_promises(emitter, &event_name, empty_args); + drain_pending_once_promises(emitter, event_name, empty_args); let capture_rejections = emitter.capture_rejections && event_name != "error"; for l in snapshot { diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index 1e357215ab..5e4407fdb2 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -1802,8 +1802,10 @@ pub unsafe extern "C" fn js_http_once( if callback == 0 { return handle; } + let roots = perry_ffi::TransientRootScope::enter(); + let callback = roots.root_addr(callback); let wrapper = - client_request_surface::create_client_once_wrapper(handle, &event, callback, false); + client_request_surface::create_client_once_wrapper(handle, &event, callback.get(), false); let mut matched = false; with_handle_mut::(handle, |request| { request @@ -1811,7 +1813,7 @@ pub unsafe extern "C" fn js_http_once( .entry(event.clone()) .or_default() .push(ClientEventListener { - callback, + callback: callback.get(), raw_wrapper: wrapper, once: true, }); diff --git a/crates/perry-ext-http/src/server/handle_dispatch.rs b/crates/perry-ext-http/src/server/handle_dispatch.rs index 86ad3ea08b..398677b020 100644 --- a/crates/perry-ext-http/src/server/handle_dispatch.rs +++ b/crates/perry-ext-http/src/server/handle_dispatch.rs @@ -126,8 +126,6 @@ extern "C" { fn js_node_http_im_resume(handle: i64); fn js_node_http_im_destroy(handle: i64); fn js_node_http_im_on(handle: i64, event_name_ptr: *const StringHeader, callback: i64) -> f64; - fn js_node_http_im_once(handle: i64, event_name_ptr: *const StringHeader, callback: i64) - -> f64; fn js_node_http_im_set_encoding(handle: i64, encoding_ptr: *const StringHeader) -> i64; fn js_node_http_im_set_timeout(handle: i64, msecs: f64, callback: i64) -> i64; fn js_node_http_im_read(handle: i64) -> f64; @@ -374,14 +372,6 @@ pub unsafe extern "C" fn js_ext_http_server_dispatch_method( } self_ref } - "once" if args.len() >= 2 => { - let event_ptr = string_arg(args[0]); - if event_ptr.is_null() { - return self_ref; - } - js_node_http_im_once(handle, event_ptr, closure_arg(Some(args[1]))); - self_ref - } "once" if args.len() >= 2 => { let event = read_string_header(string_arg(args[0]) as *mut StringHeader).unwrap_or_default(); diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 98ee742735..748ca0248f 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -352,9 +352,11 @@ enum PendingNetEvent { Data(i64, Bytes), /// Peer half-closed (FIN received); public readable-side `end` event. End(i64), - /// Writable-side shutdown requested by `socket.end()`, distinct from FIN; - /// fires the public `end` event. + /// A queued `socket.write` finished. `.1` is the completion token and + /// `.2` is the write error message when the write failed. WriteComplete(i64, u64, Option), + /// Writable-side shutdown requested by `socket.end()`, distinct from FIN. + /// `.1` is the completion token and `.2` is the shutdown error message. ShutdownComplete(i64, u64, Option), Close(i64), Error(i64, String), diff --git a/crates/perry-ext-net/src/lifecycle.rs b/crates/perry-ext-net/src/lifecycle.rs index 2dee4476c3..5b1ea5f512 100644 --- a/crates/perry-ext-net/src/lifecycle.rs +++ b/crates/perry-ext-net/src/lifecycle.rs @@ -84,10 +84,17 @@ pub(crate) unsafe fn dispatch_socket_completion(completion: u64, error: Option>(); + for completion in completions { + unsafe { + dispatch_socket_completion(completion, Some("Socket is closed".to_string())); + } + } } /// NaN-box a freshly allocated runtime string as an `f64` JS value. @@ -336,17 +343,26 @@ pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) { fn enqueue_socket_write(handle: i64, bytes: Vec, completion: u64) { let mut sockets = statics::sockets().lock().unwrap(); - if let Some(s) = sockets.get_mut(&handle) { + let failure = if let Some(s) = sockets.get_mut(&handle) { s.bytes_written = s.bytes_written.saturating_add(bytes.len() as u64); if s.cmd_tx .send(crate::SocketCommand::Write(bytes, completion)) .is_err() - && completion != 0 { - socket_completions().lock().unwrap().remove(&completion); + Some("Socket write failed") + } else { + None + } + } else { + Some("Socket is closed") + }; + drop(sockets); + if completion != 0 { + if let Some(message) = failure { + unsafe { + dispatch_socket_completion(completion, Some(message.to_string())); + } } - } else if completion != 0 { - socket_completions().lock().unwrap().remove(&completion); } } @@ -405,7 +421,10 @@ pub unsafe extern "C" fn js_ext_net_socket_write3( let completion = register_socket_completion(handle, completion); let Some(bytes) = crate::jsvalue_to_socket_bytes(chunk) else { if completion != 0 { - socket_completions().lock().unwrap().remove(&completion); + dispatch_socket_completion( + completion, + Some("Invalid data passed to socket.write".to_string()), + ); } return; }; diff --git a/crates/perry-ext-zlib/src/stream.rs b/crates/perry-ext-zlib/src/stream.rs index dc33937822..8cabbb3899 100644 --- a/crates/perry-ext-zlib/src/stream.rs +++ b/crates/perry-ext-zlib/src/stream.rs @@ -20,9 +20,10 @@ use perry_ffi::{ alloc_buffer, alloc_string, gc_register_mutable_root_scanner_named, notify_main_thread, BufferHeader, ErrorKind, GcRootVisitor, JsClosure, JsValue, RawClosureHeader, StringHeader, - TransientRootScope, + TransientRootScope, TransientRootedAddr, }; use std::collections::{HashMap, HashSet, VecDeque}; +use std::ffi::c_void; use std::io::{Read, Write}; use std::sync::Mutex; @@ -68,9 +69,23 @@ extern "C" { // synchronously before queuing codec work. pub(crate) fn js_zlib_validate_callback(callback: f64) -> i64; fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64; - fn js_async_hooks_provider_defer_destroy(async_id: u64, check_turns: u32); - fn js_async_hooks_provider_enter(async_id: u64); - fn js_async_hooks_provider_leave(async_id: u64); + fn js_async_hooks_provider_run_catching( + async_id: u64, + callback: unsafe extern "C" fn(*mut c_void) -> f64, + data: *mut c_void, + ) -> f64; + fn js_async_hooks_provider_run_catching_deferred_destroy( + async_id: u64, + check_turns: u32, + callback: unsafe extern "C" fn(*mut c_void) -> f64, + data: *mut c_void, + ) -> f64; + fn js_async_hooks_provider_run_catching_deferred_destroy_on_error( + async_id: u64, + check_turns: u32, + callback: unsafe extern "C" fn(*mut c_void) -> f64, + data: *mut c_void, + ) -> f64; fn js_native_call_method_str_key( object: f64, name_handle: i64, @@ -685,17 +700,22 @@ unsafe fn call_one_shot_callback(callback: i64, result: Result, String>) if callback == 0 { return; } + let roots = TransientRootScope::enter(); + let callback = roots.root_addr(callback); match result { Ok(bytes) => { let err = f64::from_bits(JsValue::NULL.bits()); - let out = make_buffer_f64(&bytes) - .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())); - let _ = JsClosure::from_raw(callback as *const RawClosureHeader).call2(err, out); + let out = roots.root_nanbox( + make_buffer_f64(&bytes) + .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())), + ); + let _ = JsClosure::from_raw(callback.get() as *const RawClosureHeader) + .call2(err, out.get()); } Err(msg) => { - let err = build_error_object(&msg); - let _ = JsClosure::from_raw(callback as *const RawClosureHeader) - .call2(err, f64::from_bits(JsValue::UNDEFINED.bits())); + let err = roots.root_nanbox(build_error_object(&msg)); + let _ = JsClosure::from_raw(callback.get() as *const RawClosureHeader) + .call2(err.get(), f64::from_bits(JsValue::UNDEFINED.bits())); } } } @@ -1316,6 +1336,121 @@ unsafe fn build_error_object(msg: &str) -> f64 { f64::from_bits(POINTER_TAG | (obj as u64 & POINTER_MASK)) } +struct ZlibEventDispatch { + event: Option, +} + +unsafe extern "C" fn zlib_event_dispatch_thunk(data: *mut c_void) -> f64 { + let call = &mut *(data as *mut ZlibEventDispatch); + let event = call + .event + .take() + .expect("zlib event dispatch thunk must run exactly once"); + match event { + ZlibEvent::Data(id, bytes) => { + publish_bytes_written(id); + let roots = TransientRootScope::enter(); + let callbacks = roots.root_addrs(&listeners_for(id, "data")); + let destinations = pipes_for(id) + .into_iter() + .map(|bits| roots.root_nanbox(f64::from_bits(bits))) + .collect::>(); + if callbacks.is_empty() && destinations.is_empty() { + buffer_output_for_late_consumer(&mut statics().lock().unwrap(), id, &bytes); + } else { + if !callbacks.is_empty() { + if let Some(buffer) = make_buffer_f64(&bytes) { + let buffer = roots.root_nanbox(buffer); + for callback in callbacks { + if callback.get() != 0 { + let _ = + JsClosure::from_raw(callback.get() as *const RawClosureHeader) + .call1(buffer.get()); + } + } + } + } + for destination in destinations { + forward_write(destination.get().to_bits(), &bytes); + } + } + } + ZlibEvent::Finish(id) => { + let roots = TransientRootScope::enter(); + for callback in roots.root_addrs(&listeners_for(id, "finish")) { + if callback.get() != 0 { + let _ = JsClosure::from_raw(callback.get() as *const RawClosureHeader).call0(); + } + } + } + ZlibEvent::End(id) => { + publish_bytes_written(id); + let roots = TransientRootScope::enter(); + let end_callbacks = roots.root_addrs(&listeners_for(id, "end")); + let destinations = pipes_for(id) + .into_iter() + .map(|bits| roots.root_nanbox(f64::from_bits(bits))) + .collect::>(); + let close_callbacks = roots.root_addrs(&listeners_for(id, "close")); + drop_buffered_stream(&mut statics().lock().unwrap(), id); + for callback in end_callbacks { + if callback.get() != 0 { + let _ = JsClosure::from_raw(callback.get() as *const RawClosureHeader).call0(); + } + } + for destination in destinations { + forward_end(destination.get().to_bits()); + } + for callback in close_callbacks { + if callback.get() != 0 { + let _ = JsClosure::from_raw(callback.get() as *const RawClosureHeader).call0(); + } + } + } + ZlibEvent::Error(id, message) => { + let roots = TransientRootScope::enter(); + let callbacks = roots.root_addrs(&listeners_for(id, "error")); + drop_buffered_stream(&mut statics().lock().unwrap(), id); + let error = roots.root_nanbox(build_error_object(&message)); + for callback in callbacks { + if callback.get() != 0 { + let _ = JsClosure::from_raw(callback.get() as *const RawClosureHeader) + .call1(error.get()); + } + } + } + ZlibEvent::Callback(callback) => { + if callback != 0 { + let _ = JsClosure::from_raw(callback as *const RawClosureHeader).call0(); + } + } + ZlibEvent::OneShotCallback(_, _, _) => { + unreachable!("one-shot zlib events use the two-phase provider path") + } + } + f64::from_bits(UNDEFINED) +} + +unsafe extern "C" fn zlib_empty_phase_thunk(_data: *mut c_void) -> f64 { + f64::from_bits(UNDEFINED) +} + +struct ZlibOneShotDispatch { + callback: TransientRootedAddr, + result: Option, String>>, +} + +unsafe extern "C" fn zlib_one_shot_dispatch_thunk(data: *mut c_void) -> f64 { + let call = &mut *(data as *mut ZlibOneShotDispatch); + call_one_shot_callback( + call.callback.get(), + call.result + .take() + .expect("zlib one-shot dispatch thunk must run exactly once"), + ); + f64::from_bits(UNDEFINED) +} + /// Drain queued zlib stream events on the main thread. Wired into perry-stdlib's /// `js_stdlib_process_pending` via the external-zlib-pump feature. #[no_mangle] @@ -1345,133 +1480,78 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { }; count += 1; let event_async_id = event_stream_handle(&ev).map(stream_async_id).unwrap_or(0); - let mut destroy_after_dispatch = 0; - if event_async_id != 0 { - js_async_hooks_provider_enter(event_async_id); - } - match ev { - ZlibEvent::Data(id, bytes) => { - publish_bytes_written(id); - let cbs = listeners_for(id, "data"); - let dests = pipes_for(id); - if cbs.is_empty() && dests.is_empty() { - // No consumer attached yet — buffer instead of dropping, so a - // `.on('data')`/`.pipe()` that attaches later (after `await`) - // still receives the body (flushed by `flush_buffered`), - // bounded by the per-stream + global byte caps. - buffer_output_for_late_consumer(&mut statics().lock().unwrap(), id, &bytes); - } else { - if !cbs.is_empty() { - if let Some(buf_f64) = make_buffer_f64(&bytes) { - for cb in cbs { - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(buf_f64); - } - } - } - } - for dest in dests { - forward_write(dest, &bytes); - } - } - } - ZlibEvent::Finish(id) => { - let scope = TransientRootScope::enter(); - let callbacks = scope.root_addrs(&listeners_for(id, "finish")); - for cb in callbacks { - let cb = cb.get(); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } - } - } - ZlibEvent::End(id) => { - publish_bytes_written(id); - // Defer `'end'` (keep the stream + its buffer alive) when no - // consumer has attached yet — otherwise removing the stream here - // would strand a `.on('data')`/`.on('end')` that attaches later - // (gaxios attaches them only after `await`ing the fetch), hanging - // the body-consume. `flush_buffered` re-queues End once a - // consumer attaches and the buffer has drained. - let has_consumer = - !listeners_for(id, "data").is_empty() || !pipes_for(id).is_empty(); - if !has_consumer { - let mut g = statics().lock().unwrap(); - let deferred = match g.streams.get_mut(&id) { - Some(s) => { - s.end_buffered = true; - true - } - None => false, - }; - if deferred { - // Cap how many never-consumed ended streams we retain so - // an abandoned handle (one that never gets a `'data'` - // listener or pipe) can't pin its buffered output for the - // process lifetime; drop the oldest excess. - evict_excess_buffered_ended(&mut g); - if event_async_id != 0 { - js_async_hooks_provider_leave(event_async_id); - } - continue; + if let ZlibEvent::End(id) = &ev { + // Defer `'end'` (keep the stream + its buffer alive) when no + // consumer has attached yet. Do this before entering the provider + // so a deferred stream does not emit a lifecycle phase prematurely. + let has_consumer = !listeners_for(*id, "data").is_empty() || !pipes_for(*id).is_empty(); + if !has_consumer { + let mut g = statics().lock().unwrap(); + let deferred = match g.streams.get_mut(id) { + Some(s) => { + s.end_buffered = true; + true } - // Stream already gone — release the lock and fall through to - // the (no-op) delivery + removal below. - drop(g); - } - for cb in listeners_for(id, "end") { - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } - } - for dest in pipes_for(id) { - forward_end(dest); - } - for cb in listeners_for(id, "close") { - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } - } - drop_buffered_stream(&mut statics().lock().unwrap(), id); - destroy_after_dispatch = event_async_id; - } - ZlibEvent::Callback(cb) => { - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); + None => false, + }; + if deferred { + // Cap how many never-consumed ended streams we retain so + // an abandoned handle (one that never gets a `'data'` + // listener or pipe) can't pin its buffered output for the + // process lifetime; drop the oldest excess. + evict_excess_buffered_ended(&mut g); + continue; } + // Stream already gone — release the lock and fall through to + // the (no-op) delivery + removal below. + drop(g); } - ZlibEvent::OneShotCallback(cb, result, async_id) => { + } + + let ev = match ev { + ZlibEvent::OneShotCallback(callback, result, async_id) => { let scope = TransientRootScope::enter(); - let callback = scope.root_addr(cb); + let callback = scope.root_addr(callback); // Node exposes the native codec completion and delivery of the // JavaScript callback as two phases of the same ZLIB resource. - js_async_hooks_provider_enter(async_id); - js_async_hooks_provider_leave(async_id); - js_async_hooks_provider_enter(async_id); - call_one_shot_callback(callback.get(), result); - js_async_hooks_provider_leave(async_id); - // This is queued before the callback's Promise continuation - // schedules its first user immediate, so zlib needs one more - // check turn than synchronously closed handles. - js_async_hooks_provider_defer_destroy(async_id, 4); - } - ZlibEvent::Error(id, msg) => { - let err_f64 = build_error_object(&msg); - for cb in listeners_for(id, "error") { - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call1(err_f64); - } - } - drop_buffered_stream(&mut statics().lock().unwrap(), id); - destroy_after_dispatch = event_async_id; + js_async_hooks_provider_run_catching_deferred_destroy_on_error( + async_id, + 4, + zlib_empty_phase_thunk, + std::ptr::null_mut(), + ); + let mut call = ZlibOneShotDispatch { + callback, + result: Some(result), + }; + js_async_hooks_provider_run_catching_deferred_destroy( + async_id, + 4, + zlib_one_shot_dispatch_thunk, + &mut call as *mut ZlibOneShotDispatch as *mut c_void, + ); + continue; } - } - if event_async_id != 0 { - js_async_hooks_provider_leave(event_async_id); - } - if destroy_after_dispatch != 0 { - js_async_hooks_provider_defer_destroy(destroy_after_dispatch, 4); + event => event, + }; + + let terminal = matches!(&ev, ZlibEvent::End(_) | ZlibEvent::Error(_, _)); + let mut call = ZlibEventDispatch { event: Some(ev) }; + if event_async_id == 0 { + zlib_event_dispatch_thunk(&mut call as *mut ZlibEventDispatch as *mut c_void); + } else if terminal { + js_async_hooks_provider_run_catching_deferred_destroy( + event_async_id, + 4, + zlib_event_dispatch_thunk, + &mut call as *mut ZlibEventDispatch as *mut c_void, + ); + } else { + js_async_hooks_provider_run_catching( + event_async_id, + zlib_event_dispatch_thunk, + &mut call as *mut ZlibEventDispatch as *mut c_void, + ); } } count diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 961190f9c1..914898c9e6 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -24,7 +24,9 @@ pub use provider_ffi::{ defer_destroy_after_check_turns, js_async_hooks_provider_defer_destroy, js_async_hooks_provider_destroy, js_async_hooks_provider_enter, js_async_hooks_provider_init, js_async_hooks_provider_init_with_trigger, js_async_hooks_provider_leave, - js_async_hooks_provider_run_catching, js_async_hooks_provider_run_catching_with_this, + js_async_hooks_provider_run_catching, js_async_hooks_provider_run_catching_deferred_destroy, + js_async_hooks_provider_run_catching_deferred_destroy_on_error, + js_async_hooks_provider_run_catching_with_this, }; const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; @@ -58,6 +60,8 @@ per_test_global! { pub static HOOKS_ACTIVE: AtomicUsize = AtomicUsize::new(0); static PROMISE_HOOKS_ACTIVE: AtomicUsize = AtomicUsize::new(0); static TOP_LEVEL_RESOURCE: AtomicU64 = AtomicU64::new(0); + #[cfg(test)] + static TEST_FORCE_RESOLVE_GC: AtomicUsize = AtomicUsize::new(0); } #[derive(Clone, Copy)] @@ -211,11 +215,18 @@ pub(crate) fn resolve_async_resource_handle(receiver: i64) -> Option { if !crate::value::addr_class::is_plausible_heap_addr(raw) { return None; } + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_raw_mut_ptr(raw as *mut ObjectHeader); + #[cfg(test)] + if TEST_FORCE_RESOLVE_GC.swap(0, Ordering::Relaxed) != 0 { + let _ = crate::gc::gc_collect_minor(); + } let key = js_string_from_bytes( ASYNC_RESOURCE_SUBCLASS_KEY.as_ptr(), ASYNC_RESOURCE_SUBCLASS_KEY.len() as u32, ); - let value = js_object_get_field_by_name(raw as *const ObjectHeader, key); + let value = receiver + .with_mut_ptr::(|receiver| js_object_get_field_by_name(receiver, key)); if !value.is_pointer() { return None; } @@ -223,6 +234,28 @@ pub(crate) fn resolve_async_resource_handle(receiver: i64) -> Option { is_async_resource_handle(backing).then_some(backing) } +#[cfg(test)] +pub(crate) fn test_force_next_async_resource_resolve_gc() { + TEST_FORCE_RESOLVE_GC.store(1, Ordering::Relaxed); +} + +#[cfg(test)] +pub(crate) fn test_link_async_resource_subclass(receiver: *mut ObjectHeader, backing: i64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_raw_mut_ptr(receiver); + let key = js_string_from_bytes( + ASYNC_RESOURCE_SUBCLASS_KEY.as_ptr(), + ASYNC_RESOURCE_SUBCLASS_KEY.len() as u32, + ); + receiver.with_mut_ptr::(|receiver| { + crate::object::js_object_set_field_by_name( + receiver, + key, + crate::value::js_nanbox_pointer(backing), + ); + }); +} + #[inline(always)] pub fn hooks_active() -> bool { HOOKS_ACTIVE.load(Ordering::Relaxed) != 0 @@ -889,16 +922,30 @@ pub fn run_provider_completion(type_name: &'static str, completion: impl FnOnce( true, ) }); - before(ids.async_id, ids.trigger_async_id); - let result = scope.root_nanbox_f64(completion()); - after(ids.async_id); - destroy(ids.async_id); + let outcome = try_run_resource_scope(ids, completion); + let (threw, result) = match outcome { + Ok(value) => (false, scope.root_nanbox_f64(value)), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + let destroy_outcome = crate::exception::js_call_catching(|| { + destroy(ids.async_id); + TAG_UNDEFINED_F64 + }); + let destroy_error = destroy_outcome + .err() + .map(|error| scope.root_nanbox_f64(error)); + if threw { + crate::exception::js_throw(result.get_nanbox_f64()); + } + if let Some(error) = destroy_error { + crate::exception::js_throw(error.get_nanbox_f64()); + } result.get_nanbox_f64() } /// Enter an existing provider's captured AsyncLocalStorage and execution-id /// scope for one native callback phase. -pub fn enter_resource_scope(ids: AsyncResourceIds) { +pub fn try_enter_resource_scope(ids: AsyncResourceIds) -> Result<(), f64> { let context = RESOURCES .lock() .unwrap() @@ -909,25 +956,102 @@ pub fn enter_resource_scope(ids: AsyncResourceIds) { crate::async_context::push_context_guard( crate::async_context::ContextGuardAction::RestoreSnapshot(previous), ); - before(ids.async_id, ids.trigger_async_id); crate::async_context::push_context_guard( crate::async_context::ContextGuardAction::RestoreExecutionIds, ); + let outcome = crate::exception::js_call_catching(|| { + before(ids.async_id, ids.trigger_async_id); + TAG_UNDEFINED_F64 + }); + if let Err(error) = outcome { + let scope = crate::gc::RuntimeHandleScope::new(); + let error = scope.root_nanbox_f64(error); + if let Some(action) = crate::async_context::pop_context_guard() { + crate::async_context::apply_context_guard(action); + } + if let Some(action) = crate::async_context::pop_context_guard() { + crate::async_context::apply_context_guard(action); + } + return Err(error.get_nanbox_f64()); + } + Ok(()) +} + +pub fn enter_resource_scope(ids: AsyncResourceIds) { + if let Err(error) = try_enter_resource_scope(ids) { + crate::exception::js_throw(error); + } } /// Leave a provider scope entered by [`enter_resource_scope`]. -pub fn leave_resource_scope(async_id: u64) { - let _ = crate::async_context::pop_context_guard(); - after(async_id); +pub fn try_leave_resource_scope(async_id: u64) -> Result<(), f64> { + let outcome = crate::exception::js_call_catching(|| { + after(async_id); + TAG_UNDEFINED_F64 + }); + let scope = crate::gc::RuntimeHandleScope::new(); + let (threw, result) = match outcome { + Ok(value) => (false, scope.root_nanbox_f64(value)), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + if let Some(action) = crate::async_context::pop_context_guard() { + if threw { + crate::async_context::apply_context_guard(action); + } + } if let Some(action) = crate::async_context::pop_context_guard() { crate::async_context::apply_context_guard(action); } + if threw { + Err(result.get_nanbox_f64()) + } else { + Ok(()) + } +} + +pub fn leave_resource_scope(async_id: u64) { + if let Err(error) = try_leave_resource_scope(async_id) { + crate::exception::js_throw(error); + } } pub fn run_resource_scope(ids: AsyncResourceIds, completion: impl FnOnce()) { - enter_resource_scope(ids); - completion(); - leave_resource_scope(ids.async_id); + let _ = run_resource_scope_catching(ids, || { + completion(); + TAG_UNDEFINED_F64 + }); +} + +/// Execute user code inside an existing provider and return its exception only +/// after the provider context and execution-id stacks have been restored. +pub fn try_run_resource_scope( + ids: AsyncResourceIds, + completion: impl FnOnce() -> f64, +) -> Result { + try_enter_resource_scope(ids)?; + let scope = crate::gc::RuntimeHandleScope::new(); + let outcome = crate::exception::js_call_catching(completion); + let (threw, result) = match outcome { + Ok(value) => (false, scope.root_nanbox_f64(value)), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + let leave = try_leave_resource_scope(ids.async_id); + if let Err(error) = leave { + let error = scope.root_nanbox_f64(error); + return Err(error.get_nanbox_f64()); + } + if threw { + Err(result.get_nanbox_f64()) + } else { + Ok(result.get_nanbox_f64()) + } +} + +pub fn run_resource_scope_catching(ids: AsyncResourceIds, completion: impl FnOnce() -> f64) -> f64 { + match try_run_resource_scope(ids, completion) { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + } } pub fn enqueue_gc_destroy(async_id: u64) { @@ -1269,13 +1393,15 @@ pub extern "C" fn js_async_resource_subclass_init( options_handle.get_nanbox_f64(), Some(this_handle.get_nanbox_f64()), ); - let current_this = this_handle.get_nanbox_f64(); - let raw = crate::value::js_nanbox_get_pointer(current_this) as *mut ObjectHeader; + let raw = + crate::value::js_nanbox_get_pointer(this_handle.get_nanbox_f64()) as *mut ObjectHeader; if !raw.is_null() && crate::value::addr_class::is_plausible_heap_addr(raw as usize) { let key = js_string_from_bytes( ASYNC_RESOURCE_SUBCLASS_KEY.as_ptr(), ASYNC_RESOURCE_SUBCLASS_KEY.len() as u32, ); + let raw = + crate::value::js_nanbox_get_pointer(this_handle.get_nanbox_f64()) as *mut ObjectHeader; crate::object::js_object_set_field_by_name( raw, key, @@ -1341,23 +1467,28 @@ extern "C" fn async_resource_bind_method_trampoline( return TAG_UNDEFINED_F64; } let handle = js_closure_get_capture_ptr(closure, 0); - let args_array = crate::value::js_nanbox_get_pointer(rest) as *const ArrayHeader; - let args_len = if args_array.is_null() { + let scope = crate::gc::RuntimeHandleScope::new(); + let args_array = + scope.root_raw_const_ptr(crate::value::js_nanbox_get_pointer(rest) as *const ArrayHeader); + let args_len = if args_array.get_raw_const_ptr::().is_null() { 0 } else { - js_array_length(args_array) + js_array_length(args_array.get_raw_const_ptr()) }; let callback = if args_len == 0 { TAG_UNDEFINED_F64 } else { - crate::array::js_array_get_f64(args_array, 0) + crate::array::js_array_get_f64(args_array.get_raw_const_ptr(), 0) }; + let callback = scope.root_nanbox_f64(callback); let this_arg = if args_len < 2 { TAG_UNDEFINED_F64 } else { - crate::array::js_array_get_f64(args_array, 1) + crate::array::js_array_get_f64(args_array.get_raw_const_ptr(), 1) }; - let bound = js_async_resource_bind(handle, callback, this_arg); + let this_arg = scope.root_nanbox_f64(this_arg); + let bound = + js_async_resource_bind(handle, callback.get_nanbox_f64(), this_arg.get_nanbox_f64()); if bound == 0 { TAG_UNDEFINED_F64 } else { @@ -1443,14 +1574,20 @@ pub fn try_async_resource_method_dispatch( unsafe { std::slice::from_raw_parts(args_ptr, args_len).to_vec() } }; let arg_handles = scope.root_nanbox_f64_slice(&raw_args); - let handle = resolve_async_resource_handle(receiver)?; + let receiver = scope.root_raw_mut_ptr(receiver as *mut ObjectHeader); + let handle = resolve_async_resource_handle(receiver.get_raw_mut_ptr::() as i64)?; + let handle = scope.root_raw_const_ptr(handle as *const AsyncResourceHandle); let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); Some(match method_name { - "asyncId" => js_async_resource_async_id(handle), - "triggerAsyncId" => js_async_resource_trigger_async_id(handle), + "asyncId" => { + js_async_resource_async_id(handle.get_raw_const_ptr::() as i64) + } + "triggerAsyncId" => js_async_resource_trigger_async_id( + handle.get_raw_const_ptr::() as i64, + ), "emitDestroy" => { - js_async_resource_emit_destroy(handle); - crate::value::js_nanbox_pointer(receiver) + js_async_resource_emit_destroy(handle.get_raw_const_ptr::() as i64); + crate::value::js_nanbox_pointer(receiver.get_raw_mut_ptr::() as i64) } "runInAsyncScope" => { // runInAsyncScope(fn[, thisArg, ...args]) @@ -1458,13 +1595,22 @@ pub fn try_async_resource_method_dispatch( let this_arg = args.get(1).copied().unwrap_or(TAG_UNDEFINED_F64); let rest = if args.len() > 2 { &args[2..] } else { &[] }; let args_array = pack_rest_args_array(rest); - js_async_resource_run_in_async_scope(handle, callback, this_arg, args_array) + js_async_resource_run_in_async_scope( + handle.get_raw_const_ptr::() as i64, + callback, + this_arg, + args_array, + ) } "bind" => { // bind(fn[, thisArg]) let callback = args.first().copied().unwrap_or(TAG_UNDEFINED_F64); let this_arg = args.get(1).copied().unwrap_or(TAG_UNDEFINED_F64); - let bound = js_async_resource_bind(handle, callback, this_arg); + let bound = js_async_resource_bind( + handle.get_raw_const_ptr::() as i64, + callback, + this_arg, + ); if bound == 0 { TAG_UNDEFINED_F64 } else { @@ -1527,83 +1673,58 @@ pub extern "C" fn js_async_resource_run_in_async_scope( this_arg: f64, args_array: i64, ) -> f64 { - let Some(handle) = resolve_async_resource_handle(handle) else { - return TAG_UNDEFINED_F64; - }; - if !is_callable_value(callback_value) { - throw_apply_not_function(callback_value); - } let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_handle = scope.root_raw_mut_ptr(handle as *mut ObjectHeader); let callback_handle = scope.root_nanbox_f64(callback_value); let this_arg_handle = scope.root_nanbox_f64(this_arg); + let args_array_handle = scope.root_raw_const_ptr(args_array as *const ArrayHeader); + let receiver = receiver_handle.get_raw_mut_ptr::() as i64; + let Some(handle) = resolve_async_resource_handle(receiver) else { + return TAG_UNDEFINED_F64; + }; + let handle = scope.root_raw_const_ptr(handle as *const AsyncResourceHandle); + if !is_callable_value(callback_handle.get_nanbox_f64()) { + throw_apply_not_function(callback_handle.get_nanbox_f64()); + } + let ids = unsafe { (*handle.get_raw_const_ptr::()).ids }; let rebound_bits = crate::closure::clone_closure_rebind_this( callback_handle.get_nanbox_f64().to_bits(), this_arg_handle.get_nanbox_f64(), ); let rebound_handle = scope.root_nanbox_f64(f64::from_bits(rebound_bits)); - let callback = crate::fs::extract_closure_ptr(rebound_handle.get_nanbox_f64()); - if callback.is_null() { + if crate::fs::extract_closure_ptr(rebound_handle.get_nanbox_f64()).is_null() { throw_apply_not_function(callback_handle.get_nanbox_f64()); } - let args_array_handle = scope.root_raw_const_ptr(args_array as *const ArrayHeader); - let resource = unsafe { &*(handle as *const AsyncResourceHandle) }; - let resource_context = RESOURCES - .lock() - .unwrap() - .get(&resource.ids.async_id) - .map(|meta| meta.context.clone()) - .unwrap_or_default(); - let mut resource_context = resource_context; - let resource_context_roots = crate::async_context::root_snapshot(&scope, &resource_context); - let previous = crate::async_context::enter_context(&resource_context); - // The guard owns the previous snapshot: it is GC-scanned while held, and - // if the callback throws, `js_throw` restores it during unwind (#788). - crate::async_context::push_context_guard( - crate::async_context::ContextGuardAction::RestoreSnapshot(previous), - ); - before(resource.ids.async_id, resource.ids.trigger_async_id); - crate::async_context::push_context_guard( - crate::async_context::ContextGuardAction::RestoreExecutionIds, - ); - let prev_this = crate::object::js_implicit_this_set(this_arg_handle.get_nanbox_f64()); - // Catch locally so a throwing scope still delivers `after` and restores - // the resource/context before the exception is rethrown to user code. - // The trap is installed after our guards, so throw-time unwinding leaves - // those guards for the normal cleanup below. - let outcome = crate::exception::js_call_catching(|| { - if args_array == 0 { - unsafe { js_closure_call_array(callback as i64, ptr::null(), 0) } - } else { - let arr = args_array_handle.get_raw_const_ptr::(); - let len = js_array_length(arr) as i64; - let data = if arr.is_null() { - ptr::null() + let outcome = try_run_resource_scope(ids, || { + let callback = crate::fs::extract_closure_ptr(rebound_handle.get_nanbox_f64()); + let previous_this = scope.root_nanbox_f64(crate::object::js_implicit_this_set( + this_arg_handle.get_nanbox_f64(), + )); + let callback_outcome = crate::exception::js_call_catching(|| { + if args_array_handle + .get_raw_const_ptr::() + .is_null() + { + unsafe { js_closure_call_array(callback as i64, ptr::null(), 0) } } else { - unsafe { (arr as *const u8).add(std::mem::size_of::()) as *const f64 } - }; - unsafe { js_closure_call_array(callback as i64, data, len) } + let arr = args_array_handle.get_raw_const_ptr::(); + let len = js_array_length(arr) as i64; + let data = unsafe { + (arr as *const u8).add(std::mem::size_of::()) as *const f64 + }; + unsafe { js_closure_call_array(callback as i64, data, len) } + } + }); + crate::object::js_implicit_this_set(previous_this.get_nanbox_f64()); + match callback_outcome { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), } }); - crate::object::js_implicit_this_set(prev_this); - let threw = outcome.is_err(); - let result_handle = scope.root_nanbox_f64(match outcome { - Ok(result) | Err(result) => result, - }); - // Normal exit: `after` fires hooks and pops the execution scope itself, - // so discard the silent-unwind guard rather than applying it. - let _ = crate::async_context::pop_context_guard(); - after(resource.ids.async_id); - crate::async_context::refresh_snapshot_from_roots( - &mut resource_context, - &resource_context_roots, - ); - if let Some(action) = crate::async_context::pop_context_guard() { - crate::async_context::apply_context_guard(action); + match outcome { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), } - if threw { - crate::exception::js_throw(result_handle.get_nanbox_f64()); - } - result_handle.get_nanbox_f64() } /// Trampoline body for `AsyncResource#bind`. Stored as the `func_ptr` of the @@ -1645,20 +1766,28 @@ fn register_bind_trampoline_once() { #[no_mangle] pub extern "C" fn js_async_resource_bind(handle: i64, callback_value: f64, this_arg: f64) -> i64 { - validate_bind_callback(callback_value); - let Some(handle) = resolve_async_resource_handle(handle) else { - return 0; - }; - register_bind_trampoline_once(); let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_handle = scope.root_raw_mut_ptr(handle as *mut ObjectHeader); let callback_handle = scope.root_nanbox_f64(callback_value); let this_arg_handle = scope.root_nanbox_f64(this_arg); + validate_bind_callback(callback_handle.get_nanbox_f64()); + let Some(handle) = + resolve_async_resource_handle(receiver_handle.get_raw_mut_ptr::() as i64) + else { + return 0; + }; + let handle = scope.root_raw_const_ptr(handle as *const AsyncResourceHandle); + register_bind_trampoline_once(); let closure = js_closure_alloc(async_resource_bind_trampoline as *const u8, 3); if closure.is_null() { return 0; } let closure_handle = scope.root_raw_mut_ptr(closure); - js_closure_set_capture_ptr(closure_handle.get_raw_mut_ptr(), 0, handle); + js_closure_set_capture_ptr( + closure_handle.get_raw_mut_ptr(), + 0, + handle.get_raw_const_ptr::() as i64, + ); js_closure_set_capture_f64( closure_handle.get_raw_mut_ptr(), 1, @@ -1669,9 +1798,9 @@ pub extern "C" fn js_async_resource_bind(handle: i64, callback_value: f64, this_ 2, this_arg_handle.get_nanbox_f64(), ); - if let Some(length) = - crate::closure::closure_length(crate::fs::extract_closure_ptr(callback_value)) - { + if let Some(length) = crate::closure::closure_length(crate::fs::extract_closure_ptr( + callback_handle.get_nanbox_f64(), + )) { crate::object::set_builtin_closure_length( closure_handle.get_raw_mut_ptr::() as usize, length, diff --git a/crates/perry-runtime/src/async_hooks/provider_ffi.rs b/crates/perry-runtime/src/async_hooks/provider_ffi.rs index b50e7f3657..7570dc316a 100644 --- a/crates/perry-runtime/src/async_hooks/provider_ffi.rs +++ b/crates/perry-runtime/src/async_hooks/provider_ffi.rs @@ -1,8 +1,8 @@ //! Exception-safe callback bridges for separately linked async providers. use super::{ - destroy, enter_resource_scope, init_resource, init_resource_with_trigger, leave_resource_scope, - AsyncResourceIds, RESOURCES, + destroy, init_resource, init_resource_with_trigger, try_enter_resource_scope, + try_leave_resource_scope, AsyncResourceIds, RESOURCES, }; extern "C" fn deferred_destroy_step(closure: *const crate::closure::ClosureHeader) -> f64 { @@ -45,6 +45,19 @@ pub fn defer_destroy_after_check_turns(async_id: u64, check_turns: u32) { } } +fn provider_ids(async_id: u64) -> AsyncResourceIds { + let trigger_async_id = RESOURCES + .lock() + .unwrap() + .get(&async_id) + .map(|meta| meta.trigger_async_id) + .unwrap_or(0); + AsyncResourceIds { + async_id, + trigger_async_id, + } +} + /// C ABI used by separately-linked native providers such as perry-ext-zlib. #[no_mangle] pub unsafe extern "C" fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64 { @@ -83,21 +96,16 @@ pub unsafe extern "C" fn js_async_hooks_provider_init_with_trigger( #[no_mangle] pub extern "C" fn js_async_hooks_provider_enter(async_id: u64) { - let trigger_async_id = RESOURCES - .lock() - .unwrap() - .get(&async_id) - .map(|meta| meta.trigger_async_id) - .unwrap_or(0); - enter_resource_scope(AsyncResourceIds { - async_id, - trigger_async_id, - }); + if let Err(error) = try_enter_resource_scope(provider_ids(async_id)) { + crate::exception::js_throw(error); + } } #[no_mangle] pub extern "C" fn js_async_hooks_provider_leave(async_id: u64) { - leave_resource_scope(async_id); + if let Err(error) = try_leave_resource_scope(async_id) { + crate::exception::js_throw(error); + } } #[no_mangle] @@ -120,17 +128,87 @@ pub unsafe extern "C" fn js_async_hooks_provider_run_catching( callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64, data: *mut std::ffi::c_void, ) -> f64 { - js_async_hooks_provider_enter(async_id); - let outcome = crate::exception::js_call_catching(|| callback(data)); + provider_run_catching(async_id, DestroyPolicy::Never, callback, data) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DestroyPolicy { + Never, + Always(u32), + OnError(u32), +} + +/// Variant used by terminal external-provider events. Teardown is scheduled +/// after scope restoration and before a caught JavaScript exception is +/// rethrown, so a throwing listener cannot strand the resource. +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_provider_run_catching_deferred_destroy( + async_id: u64, + check_turns: u32, + callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64, + data: *mut std::ffi::c_void, +) -> f64 { + provider_run_catching(async_id, DestroyPolicy::Always(check_turns), callback, data) +} + +/// Schedule terminal teardown only when scope entry, the callback, or scope +/// exit throws. This lets a multi-phase provider protect an early phase while +/// leaving its normal destroy timing to the final phase. +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_provider_run_catching_deferred_destroy_on_error( + async_id: u64, + check_turns: u32, + callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64, + data: *mut std::ffi::c_void, +) -> f64 { + provider_run_catching( + async_id, + DestroyPolicy::OnError(check_turns), + callback, + data, + ) +} + +unsafe fn provider_run_catching( + async_id: u64, + destroy_policy: DestroyPolicy, + callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64, + data: *mut std::ffi::c_void, +) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); + if let Err(error) = try_enter_resource_scope(provider_ids(async_id)) { + let error = scope.root_nanbox_f64(error); + if let DestroyPolicy::Always(turns) | DestroyPolicy::OnError(turns) = destroy_policy { + defer_destroy_after_check_turns(async_id, turns); + } + crate::exception::js_throw(error.get_nanbox_f64()); + } + let outcome = crate::exception::js_call_catching(|| callback(data)); let (threw, result) = match outcome { Ok(value) => (false, scope.root_nanbox_f64(value)), Err(error) => (true, scope.root_nanbox_f64(error)), }; - js_async_hooks_provider_leave(async_id); + let leave = try_leave_resource_scope(async_id); + let (leave_threw, leave_result) = match leave { + Ok(()) => ( + false, + scope.root_nanbox_f64(f64::from_bits(crate::value::TAG_UNDEFINED)), + ), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + if let DestroyPolicy::Always(turns) = destroy_policy { + defer_destroy_after_check_turns(async_id, turns); + } else if let DestroyPolicy::OnError(turns) = destroy_policy { + if threw || leave_threw { + defer_destroy_after_check_turns(async_id, turns); + } + } if threw { crate::exception::js_throw(result.get_nanbox_f64()); } + if leave_threw { + crate::exception::js_throw(leave_result.get_nanbox_f64()); + } result.get_nanbox_f64() } @@ -147,7 +225,16 @@ pub unsafe extern "C" fn js_async_hooks_provider_run_catching_with_this( ) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let this_value = scope.root_nanbox_f64(this_value); - js_async_hooks_provider_enter(async_id); + if let Err(error) = try_enter_resource_scope(provider_ids(async_id)) { + let error = scope.root_nanbox_f64(error); + if destroy_after != 0 { + let _ = crate::exception::js_call_catching(|| { + destroy(async_id); + f64::from_bits(crate::value::TAG_UNDEFINED) + }); + } + crate::exception::js_throw(error.get_nanbox_f64()); + } let previous_this = scope.root_nanbox_f64(crate::object::js_implicit_this_set( this_value.get_nanbox_f64(), )); @@ -157,12 +244,35 @@ pub unsafe extern "C" fn js_async_hooks_provider_run_catching_with_this( Err(error) => (true, scope.root_nanbox_f64(error)), }; crate::object::js_implicit_this_set(previous_this.get_nanbox_f64()); - js_async_hooks_provider_leave(async_id); - if destroy_after != 0 { - js_async_hooks_provider_destroy(async_id); - } + let leave = try_leave_resource_scope(async_id); + let (leave_threw, leave_result) = match leave { + Ok(()) => ( + false, + scope.root_nanbox_f64(f64::from_bits(crate::value::TAG_UNDEFINED)), + ), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + let destroy_outcome = (destroy_after != 0).then(|| { + crate::exception::js_call_catching(|| { + destroy(async_id); + f64::from_bits(crate::value::TAG_UNDEFINED) + }) + }); + let (destroy_threw, destroy_result) = match destroy_outcome { + Some(Err(error)) => (true, scope.root_nanbox_f64(error)), + _ => ( + false, + scope.root_nanbox_f64(f64::from_bits(crate::value::TAG_UNDEFINED)), + ), + }; if threw { crate::exception::js_throw(result.get_nanbox_f64()); } + if leave_threw { + crate::exception::js_throw(leave_result.get_nanbox_f64()); + } + if destroy_threw { + crate::exception::js_throw(destroy_result.get_nanbox_f64()); + } result.get_nanbox_f64() } diff --git a/crates/perry-runtime/src/async_hooks/test_support.rs b/crates/perry-runtime/src/async_hooks/test_support.rs index be53907020..ce0ec4a56f 100644 --- a/crates/perry-runtime/src/async_hooks/test_support.rs +++ b/crates/perry-runtime/src/async_hooks/test_support.rs @@ -63,6 +63,26 @@ pub(crate) fn test_async_hooks_scanner_snapshot() -> (usize, u64) { mod tests { use super::*; + extern "C" fn throwing_lifecycle_hook(_closure: *const ClosureHeader, _async_id: f64) -> f64 { + crate::exception::js_throw(73.0) + } + + fn enable_throwing_lifecycle_hook(before_phase: bool) { + let callback = js_closure_alloc(throwing_lifecycle_hook as *const u8, 0); + let mut callbacks = HookCallbacks::empty(); + if before_phase { + callbacks.before = callback; + } else { + callbacks.after = callback; + } + HOOKS.lock().unwrap().push(HookRecord { + callbacks, + enabled: true, + track_promises: false, + }); + HOOKS_ACTIVE.store(1, Ordering::Relaxed); + } + // #7680: no lock needed here anymore. The per-test globals isolate this // thread's reset and resource-id sequence from concurrent tests. #[test] @@ -84,6 +104,33 @@ mod tests { assert_eq!(execution_async_id_u64(), 0); } + #[test] + fn resource_scope_restores_context_when_lifecycle_hooks_throw() { + const STORE: i64 = -9_401; + for before_phase in [true, false] { + reset_for_tests(); + crate::async_context::clear_store(STORE); + crate::async_context::enter_with(STORE, 11.0); + let ids = init_resource("throwing-scope", TAG_UNDEFINED_F64, true); + crate::async_context::enter_with(STORE, 22.0); + enable_throwing_lifecycle_hook(before_phase); + + let mut completion_ran = false; + let outcome = try_run_resource_scope(ids, || { + completion_ran = true; + TAG_UNDEFINED_F64 + }); + + assert_eq!(outcome.unwrap_err().to_bits(), 73.0f64.to_bits()); + assert_eq!(completion_ran, !before_phase); + assert_eq!(execution_async_id_u64(), 0); + assert!(EXECUTION_STACK.with(|stack| stack.borrow().is_empty())); + assert_eq!(crate::async_context::get_store(STORE), Some(22.0)); + crate::async_context::clear_store(STORE); + } + reset_for_tests(); + } + #[test] fn track_promises_filters_hooks_and_activity() { reset_for_tests(); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs index b8a1194344..29217da550 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs @@ -1,5 +1,43 @@ use super::*; +extern "C" fn test_current_async_id(_closure: *const crate::closure::ClosureHeader) -> f64 { + crate::async_hooks::execution_async_id_u64() as f64 +} + +#[test] +fn test_async_resource_subclass_run_in_scope_roots_inputs_during_key_alloc_gc() { + let _async_hook_guard = AsyncHookRuntimeTestGuard::new(); + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force_evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + let _verify_evacuation = crate::gc::knob_overrides::VerifyEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + let resource_type = test_string_value(b"SubclassResource"); + let backing = crate::async_hooks::js_async_resource_new( + resource_type, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + let expected_async_id = crate::async_hooks::js_async_resource_async_id(backing); + let receiver = crate::object::js_object_alloc(0, 1); + crate::async_hooks::test_link_async_resource_subclass(receiver, backing); + let callback = crate::closure::js_closure_alloc(test_current_async_id as *const u8, 0); + + crate::async_hooks::test_force_next_async_resource_resolve_gc(); + let before = crate::gc::copying_minor_cycles(); + let result = crate::async_hooks::js_async_resource_run_in_async_scope( + receiver as i64, + f64::from_bits(ptr_bits(callback as usize)), + f64::from_bits(crate::value::TAG_UNDEFINED), + 0, + ); + let after = crate::gc::copying_minor_cycles(); + + assert!(after > before, "the resolver must complete a copying minor"); + assert_eq!(result, expected_async_id); + assert_eq!(crate::async_hooks::execution_async_id_u64(), 0); +} + #[test] fn test_async_hook_option_lookup_roots_callbacks_across_copied_minor_gc() { let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); diff --git a/crates/perry-stdlib/src/webcrypto/digest.rs b/crates/perry-stdlib/src/webcrypto/digest.rs index e56f1396de..d464638195 100644 --- a/crates/perry-stdlib/src/webcrypto/digest.rs +++ b/crates/perry-stdlib/src/webcrypto/digest.rs @@ -55,11 +55,11 @@ pub unsafe extern "C" fn js_webcrypto_digest(algo_bits: f64, data_bits: f64) -> let scope = perry_runtime::gc::RuntimeHandleScope::new(); let value = scope.root_nanbox_f64(f64::from_bits(JSValue::pointer(buf as *const u8).bits())); let promise = scope.root_raw_mut_ptr(perry_runtime::promise::js_promise_new()); + let cl = perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); + let cl = scope.root_raw_mut_ptr(cl); let promise_val = promise.with_mut_ptr(|promise: *mut Promise| { f64::from_bits(JSValue::pointer(promise as *const u8).bits()) }); - let cl = perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); - let cl = scope.root_raw_mut_ptr(cl); perry_runtime::closure::js_closure_set_capture_ptr( cl.get_raw_mut_ptr(), 0, diff --git a/crates/perry-stdlib/src/worker_threads/worker_pump.rs b/crates/perry-stdlib/src/worker_threads/worker_pump.rs index 6e05f1d72e..2fe789e91f 100644 --- a/crates/perry-stdlib/src/worker_threads/worker_pump.rs +++ b/crates/perry-stdlib/src/worker_threads/worker_pump.rs @@ -261,51 +261,53 @@ fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { "message" | "messageerror" => async_resources[2], _ => async_resources[0], }; - perry_runtime::async_hooks::enter_resource_scope(resource); - let property_name = match event { - "message" => Some("onmessage"), - "error" => Some("onerror"), - "messageerror" => Some("onmessageerror"), - _ => None, - }; - let property_handler = property_name - .and_then(|name| object_event_handler(object_h.get_nanbox_f64().to_bits(), name)) - .map(|bits| scope.root_nanbox_f64(f64::from_bits(bits))); - let needs_event = property_handler.is_some() || callbacks.iter().any(|(_, web)| *web); - let event_handle = if needs_event { - let data = (event == "message") - .then(|| arg_handle.as_ref().map(|h| h.get_nanbox_f64())) - .flatten(); - let ev = event_object(event, object_h.get_nanbox_f64().to_bits(), data); - Some(scope.root_nanbox_f64(ev)) - } else { - None - }; - - if let (Some(callback_h), Some(event_h)) = (property_handler, event_handle.as_ref()) { - call_callback1( - callback_h.get_nanbox_f64().to_bits(), - object_h.get_nanbox_f64().to_bits(), - event_h.get_nanbox_f64(), - ); - } - - for (callback_h, web_event) in callbacks { - let closure_ptr = perry_runtime::value::js_nanbox_get_pointer(callback_h.get_nanbox_f64()); - if closure_ptr == 0 { - continue; - } - let closure = closure_ptr as *const ClosureHeader; - let call_arg = if web_event { - event_handle.as_ref().map(|h| h.get_nanbox_f64()) - } else { - arg_handle.as_ref().map(|h| h.get_nanbox_f64()) + perry_runtime::async_hooks::run_resource_scope_catching(resource, || { + let property_name = match event { + "message" => Some("onmessage"), + "error" => Some("onerror"), + "messageerror" => Some("onmessageerror"), + _ => None, }; - if let Some(arg) = call_arg { - perry_runtime::closure::js_closure_call1(closure, arg); + let property_handler = property_name + .and_then(|name| object_event_handler(object_h.get_nanbox_f64().to_bits(), name)) + .map(|bits| scope.root_nanbox_f64(f64::from_bits(bits))); + let needs_event = property_handler.is_some() || callbacks.iter().any(|(_, web)| *web); + let event_handle = if needs_event { + let data = (event == "message") + .then(|| arg_handle.as_ref().map(|h| h.get_nanbox_f64())) + .flatten(); + let ev = event_object(event, object_h.get_nanbox_f64().to_bits(), data); + Some(scope.root_nanbox_f64(ev)) } else { - perry_runtime::closure::js_closure_call0(closure); + None + }; + + if let (Some(callback_h), Some(event_h)) = (property_handler, event_handle.as_ref()) { + call_callback1( + callback_h.get_nanbox_f64().to_bits(), + object_h.get_nanbox_f64().to_bits(), + event_h.get_nanbox_f64(), + ); } - } - perry_runtime::async_hooks::leave_resource_scope(resource.async_id); + + for (callback_h, web_event) in callbacks { + let closure_ptr = + perry_runtime::value::js_nanbox_get_pointer(callback_h.get_nanbox_f64()); + if closure_ptr == 0 { + continue; + } + let closure = closure_ptr as *const ClosureHeader; + let call_arg = if web_event { + event_handle.as_ref().map(|h| h.get_nanbox_f64()) + } else { + arg_handle.as_ref().map(|h| h.get_nanbox_f64()) + }; + if let Some(arg) = call_arg { + perry_runtime::closure::js_closure_call1(closure, arg); + } else { + perry_runtime::closure::js_closure_call0(closure); + } + } + js_undefined() + }); } diff --git a/crates/perry-stdlib/src/zlib.rs b/crates/perry-stdlib/src/zlib.rs index 0b4bd1902e..14e4aac6e6 100644 --- a/crates/perry-stdlib/src/zlib.rs +++ b/crates/perry-stdlib/src/zlib.rs @@ -1351,113 +1351,182 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { .and_then(|streams| streams.get(id).map(|stream| stream.async_ids)), _ => None, }; - if let Some(ids) = event_ids { - perry_runtime::async_hooks::enter_resource_scope(ids); - } - let mut destroy_after_dispatch = None; - match ev { - ZlibEvent::Data(id, bytes) => { - publish_zlib_bytes_written(id); - let cbs = listeners_for(id, "data"); - if !cbs.is_empty() { - if let Some(buf_f64) = make_buffer(&bytes) { - for cb in cbs { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, buf_f64); + let destroy_after_dispatch = match (&ev, event_ids) { + (ZlibEvent::End(_) | ZlibEvent::Error(_, _), Some(ids)) => Some(ids.async_id), + _ => None, + }; + let dispatch = || { + match ev { + ZlibEvent::Data(id, bytes) => { + publish_zlib_bytes_written(id); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callbacks = listeners_for(id, "data") + .into_iter() + .map(|callback| scope.root_raw_const_ptr(callback as *const ClosureHeader)) + .collect::>(); + let destinations = pipes_for(id) + .into_iter() + .map(|destination| scope.root_nanbox_f64(f64::from_bits(destination))) + .collect::>(); + if !callbacks.is_empty() { + if let Some(buf_f64) = make_buffer(&bytes) { + let buffer = scope.root_nanbox_f64(buf_f64); + for callback in callbacks { + let callback = callback.get_raw_const_ptr::(); + if !callback.is_null() { + js_closure_call1(callback, buffer.get_nanbox_f64()); + } } } } - } - // Fresh Buffer per pipe dest (the chunk lives in the owned - // `bytes`, so this is safe even after listener callbacks GC'd). - for dest in pipes_for(id) { - forward_write(dest, &bytes); - } - } - ZlibEvent::End(id) => { - publish_zlib_bytes_written(id); - for cb in listeners_for(id, "end") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + // Fresh Buffer per pipe dest (the chunk lives in the owned + // `bytes`, so this is safe even after listener callbacks GC'd). + for destination in destinations { + forward_write(destination.get_nanbox_f64().to_bits(), &bytes); } } - for cb in listeners_for(id, "finish") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + ZlibEvent::End(id) => { + publish_zlib_bytes_written(id); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let end_callbacks = listeners_for(id, "end") + .into_iter() + .map(|callback| scope.root_raw_const_ptr(callback as *const ClosureHeader)) + .collect::>(); + let finish_callbacks = listeners_for(id, "finish") + .into_iter() + .map(|callback| scope.root_raw_const_ptr(callback as *const ClosureHeader)) + .collect::>(); + let destinations = pipes_for(id) + .into_iter() + .map(|destination| scope.root_nanbox_f64(f64::from_bits(destination))) + .collect::>(); + let close_callbacks = listeners_for(id, "close") + .into_iter() + .map(|callback| scope.root_raw_const_ptr(callback as *const ClosureHeader)) + .collect::>(); + ZLIB_LISTENERS.lock().unwrap().remove(&id); + ZLIB_STREAMS.lock().unwrap().remove(&id); + for callback in end_callbacks { + let callback = callback.get_raw_const_ptr::(); + if !callback.is_null() { + js_closure_call0(callback); + } } - } - for dest in pipes_for(id) { - forward_end(dest); - } - for cb in listeners_for(id, "close") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + for callback in finish_callbacks { + let callback = callback.get_raw_const_ptr::(); + if !callback.is_null() { + js_closure_call0(callback); + } + } + for destination in destinations { + forward_end(destination.get_nanbox_f64().to_bits()); + } + for callback in close_callbacks { + let callback = callback.get_raw_const_ptr::(); + if !callback.is_null() { + js_closure_call0(callback); + } } } - ZLIB_LISTENERS.lock().unwrap().remove(&id); - ZLIB_STREAMS.lock().unwrap().remove(&id); - destroy_after_dispatch = event_ids.map(|ids| ids.async_id); - } - ZlibEvent::Callback(cb) => { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + ZlibEvent::Callback(cb) => { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callback = scope.root_raw_const_ptr(cb as *const ClosureHeader); + let callback = callback.get_raw_const_ptr::(); + if !callback.is_null() { + js_closure_call0(callback); + } } - } - ZlibEvent::OneShotCallback(cb, result, ids) => { - // Node exposes two ZLIB provider phases for one-shot helpers: - // native compression completion, followed by delivery of the - // JavaScript callback. They intentionally share one resource. - perry_runtime::async_hooks::run_resource_scope(ids, || {}); - perry_runtime::async_hooks::enter_resource_scope(ids); - if cb != 0 { - match result { - Ok(bytes) => { - if let Some(buf_f64) = make_buffer(&bytes) { - js_closure_call2( - cb as *const ClosureHeader, - f64::from_bits(JSValue::null().bits()), - buf_f64, - ); - } else { - let err_f64 = build_zlib_error("Buffer allocation failed"); - js_closure_call2( - cb as *const ClosureHeader, - err_f64, - f64::from_bits(JSValue::undefined().bits()), - ); + ZlibEvent::OneShotCallback(cb, result, ids) => { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callback = scope.root_raw_const_ptr(cb as *const ClosureHeader); + // Node exposes two ZLIB provider phases for one-shot helpers: + // native compression completion, followed by delivery of the + // JavaScript callback. They intentionally share one resource. + let first_phase = + perry_runtime::async_hooks::try_run_resource_scope(ids, || { + f64::from_bits(JSValue::undefined().bits()) + }); + if let Err(error) = first_phase { + let error = scope.root_nanbox_f64(error); + perry_runtime::async_hooks::defer_destroy_after_check_turns( + ids.async_id, + 4, + ); + perry_runtime::exception::js_throw(error.get_nanbox_f64()); + } + let outcome = perry_runtime::async_hooks::try_run_resource_scope(ids, || { + if !callback.get_raw_const_ptr::().is_null() { + match result { + Ok(bytes) => { + if let Some(buf_f64) = make_buffer(&bytes) { + let buffer = scope.root_nanbox_f64(buf_f64); + js_closure_call2( + callback.get_raw_const_ptr::(), + f64::from_bits(JSValue::null().bits()), + buffer.get_nanbox_f64(), + ); + } else { + let error = scope.root_nanbox_f64(build_zlib_error( + "Buffer allocation failed", + )); + js_closure_call2( + callback.get_raw_const_ptr::(), + error.get_nanbox_f64(), + f64::from_bits(JSValue::undefined().bits()), + ); + } + } + Err(msg) => { + let error = scope.root_nanbox_f64(build_zlib_error(&msg)); + js_closure_call2( + callback.get_raw_const_ptr::(), + error.get_nanbox_f64(), + f64::from_bits(JSValue::undefined().bits()), + ); + } } } - Err(msg) => { - let err_f64 = build_zlib_error(&msg); - js_closure_call2( - cb as *const ClosureHeader, - err_f64, - f64::from_bits(JSValue::undefined().bits()), - ); - } + f64::from_bits(JSValue::undefined().bits()) + }); + let error = outcome.err().map(|error| scope.root_nanbox_f64(error)); + perry_runtime::async_hooks::defer_destroy_after_check_turns(ids.async_id, 4); + if let Some(error) = error { + perry_runtime::exception::js_throw(error.get_nanbox_f64()); } } - perry_runtime::async_hooks::leave_resource_scope(ids.async_id); - perry_runtime::async_hooks::defer_destroy_after_check_turns(ids.async_id, 4); - } - ZlibEvent::Error(id, msg) => { - let err_f64 = build_zlib_error(&msg); - for cb in listeners_for(id, "error") { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, err_f64); + ZlibEvent::Error(id, msg) => { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callbacks = listeners_for(id, "error") + .into_iter() + .map(|callback| scope.root_raw_const_ptr(callback as *const ClosureHeader)) + .collect::>(); + ZLIB_LISTENERS.lock().unwrap().remove(&id); + ZLIB_STREAMS.lock().unwrap().remove(&id); + let error = scope.root_nanbox_f64(build_zlib_error(&msg)); + for callback in callbacks { + let callback = callback.get_raw_const_ptr::(); + if !callback.is_null() { + js_closure_call1(callback, error.get_nanbox_f64()); + } } } - ZLIB_LISTENERS.lock().unwrap().remove(&id); - ZLIB_STREAMS.lock().unwrap().remove(&id); - destroy_after_dispatch = event_ids.map(|ids| ids.async_id); } - } - if let Some(ids) = event_ids { - perry_runtime::async_hooks::leave_resource_scope(ids.async_id); - } + f64::from_bits(JSValue::undefined().bits()) + }; + let outcome = match event_ids { + Some(ids) => perry_runtime::async_hooks::try_run_resource_scope(ids, dispatch), + None => Ok(dispatch()), + }; + let error_scope = perry_runtime::gc::RuntimeHandleScope::new(); + let error = outcome + .err() + .map(|error| error_scope.root_nanbox_f64(error)); if let Some(async_id) = destroy_after_dispatch { perry_runtime::async_hooks::defer_destroy_after_check_turns(async_id, 4); } + if let Some(error) = error { + perry_runtime::exception::js_throw(error.get_nanbox_f64()); + } } count } diff --git a/scripts/thread_local_cold_allowlist.json b/scripts/thread_local_cold_allowlist.json index db158dedeb..89f27d4433 100644 --- a/scripts/thread_local_cold_allowlist.json +++ b/scripts/thread_local_cold_allowlist.json @@ -1,6 +1,6 @@ { "_comment": "Files still declaring raw `thread_local!`. Every entry is a declaration that pays `_tlv_get_addr` on Darwin; the count is a ratchet, so adding one to an already-listed file fails too. New code should use `crate::perry_thread_local!` \u2014 see crates/perry-runtime/src/tls_hot.rs. Regenerate with scripts/check_thread_locals.py --update.", - "_hot_declarations": 263, + "_hot_declarations": 261, "files": { "crates/perry-runtime/src/agent.rs": 1, "crates/perry-runtime/src/arena/block.rs": 2, diff --git a/test-parity/node-suite/async_hooks/integrations/events-emitter.ts b/test-parity/node-suite/async_hooks/integrations/events-emitter.ts index 5f6409530d..ff84b7681e 100644 --- a/test-parity/node-suite/async_hooks/integrations/events-emitter.ts +++ b/test-parity/node-suite/async_hooks/integrations/events-emitter.ts @@ -30,3 +30,18 @@ await storage.run( ); console.log("events outside:", String(storage.getStore())); + +let eventNameConversions = 0; +const convertedName = { + toString() { + eventNameConversions += 1; + return "converted"; + }, +}; +const conversionEmitter = new EventEmitter(); +let convertedValue = "missing"; +conversionEmitter.on("converted", (value) => { + convertedValue = value; +}); +conversionEmitter.emit(convertedName as unknown as string, "value"); +console.log("event name conversion:", eventNameConversions, convertedValue); diff --git a/test-parity/node-suite/async_hooks/resource/shadowed-spread-parent.ts b/test-parity/node-suite/async_hooks/resource/shadowed-spread-parent.ts new file mode 100644 index 0000000000..f7d24fcc16 --- /dev/null +++ b/test-parity/node-suite/async_hooks/resource/shadowed-spread-parent.ts @@ -0,0 +1,19 @@ +// A lexical class with a builtin-looking name must still run its own spread +// constructor path instead of being lowered as a native AsyncResource parent. +class AsyncResource { + readonly marker: string; + constructor(...values: string[]) { + this.marker = `user:${values.join(",")}`; + } +} + +class ShadowedResource extends AsyncResource { + constructor(...values: string[]) { + super(...values); + } +} + +console.log( + "shadowed spread parent:", + new ShadowedResource("first", "second").marker, +); From 57515a1a8bc58a5b85b52f1562616e94580bd639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 14:06:54 +0200 Subject: [PATCH 05/15] refactor(async_hooks): split resource scopes --- crates/perry-ext-net/src/lib.rs | 6 +- crates/perry-runtime/src/async_hooks.rs | 154 +----------------- .../perry-runtime/src/async_hooks/scopes.rs | 149 +++++++++++++++++ 3 files changed, 157 insertions(+), 152 deletions(-) create mode 100644 crates/perry-runtime/src/async_hooks/scopes.rs diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 748ca0248f..548c844021 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -352,11 +352,9 @@ enum PendingNetEvent { Data(i64, Bytes), /// Peer half-closed (FIN received); public readable-side `end` event. End(i64), - /// A queued `socket.write` finished. `.1` is the completion token and - /// `.2` is the write error message when the write failed. + /// A queued `socket.write` finished with a completion token and optional error. WriteComplete(i64, u64, Option), - /// Writable-side shutdown requested by `socket.end()`, distinct from FIN. - /// `.1` is the completion token and `.2` is the shutdown error message. + /// `socket.end()` writable shutdown with a completion token and optional error. ShutdownComplete(i64, u64, Option), Close(i64), Error(i64, String), diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 914898c9e6..c3375b09b6 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -28,6 +28,12 @@ pub use provider_ffi::{ js_async_hooks_provider_run_catching_deferred_destroy_on_error, js_async_hooks_provider_run_catching_with_this, }; +mod scopes; +pub use scopes::{ + enter_resource_scope, leave_resource_scope, run_provider_completion, run_resource_scope, + run_resource_scope_catching, try_enter_resource_scope, try_leave_resource_scope, + try_run_resource_scope, +}; const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; @@ -906,154 +912,6 @@ pub fn destroy_promise(async_id: u64) { destroy_with_kind(async_id, true); } -/// Run a synchronous native completion as an observable async-hooks provider. -/// The operation may already have done its blocking work eagerly, but its -/// Promise settlement still needs the same provider execution/resource scope -/// Node gives a libuv completion. The returned JS value is rooted across hook -/// callbacks, which are arbitrary allocating user code. -pub fn run_provider_completion(type_name: &'static str, completion: impl FnOnce() -> f64) -> f64 { - let scope = crate::gc::RuntimeHandleScope::new(); - let resource = crate::object::js_object_alloc_null_proto(0, 0); - let resource_handle = scope.root_raw_mut_ptr(resource); - let ids = resource_handle.with_mut_ptr::(|resource| { - init_resource( - type_name, - crate::value::js_nanbox_pointer(resource as i64), - true, - ) - }); - let outcome = try_run_resource_scope(ids, completion); - let (threw, result) = match outcome { - Ok(value) => (false, scope.root_nanbox_f64(value)), - Err(error) => (true, scope.root_nanbox_f64(error)), - }; - let destroy_outcome = crate::exception::js_call_catching(|| { - destroy(ids.async_id); - TAG_UNDEFINED_F64 - }); - let destroy_error = destroy_outcome - .err() - .map(|error| scope.root_nanbox_f64(error)); - if threw { - crate::exception::js_throw(result.get_nanbox_f64()); - } - if let Some(error) = destroy_error { - crate::exception::js_throw(error.get_nanbox_f64()); - } - result.get_nanbox_f64() -} - -/// Enter an existing provider's captured AsyncLocalStorage and execution-id -/// scope for one native callback phase. -pub fn try_enter_resource_scope(ids: AsyncResourceIds) -> Result<(), f64> { - let context = RESOURCES - .lock() - .unwrap() - .get(&ids.async_id) - .map(|meta| meta.context.clone()) - .unwrap_or_default(); - let previous = crate::async_context::enter_context(&context); - crate::async_context::push_context_guard( - crate::async_context::ContextGuardAction::RestoreSnapshot(previous), - ); - crate::async_context::push_context_guard( - crate::async_context::ContextGuardAction::RestoreExecutionIds, - ); - let outcome = crate::exception::js_call_catching(|| { - before(ids.async_id, ids.trigger_async_id); - TAG_UNDEFINED_F64 - }); - if let Err(error) = outcome { - let scope = crate::gc::RuntimeHandleScope::new(); - let error = scope.root_nanbox_f64(error); - if let Some(action) = crate::async_context::pop_context_guard() { - crate::async_context::apply_context_guard(action); - } - if let Some(action) = crate::async_context::pop_context_guard() { - crate::async_context::apply_context_guard(action); - } - return Err(error.get_nanbox_f64()); - } - Ok(()) -} - -pub fn enter_resource_scope(ids: AsyncResourceIds) { - if let Err(error) = try_enter_resource_scope(ids) { - crate::exception::js_throw(error); - } -} - -/// Leave a provider scope entered by [`enter_resource_scope`]. -pub fn try_leave_resource_scope(async_id: u64) -> Result<(), f64> { - let outcome = crate::exception::js_call_catching(|| { - after(async_id); - TAG_UNDEFINED_F64 - }); - let scope = crate::gc::RuntimeHandleScope::new(); - let (threw, result) = match outcome { - Ok(value) => (false, scope.root_nanbox_f64(value)), - Err(error) => (true, scope.root_nanbox_f64(error)), - }; - if let Some(action) = crate::async_context::pop_context_guard() { - if threw { - crate::async_context::apply_context_guard(action); - } - } - if let Some(action) = crate::async_context::pop_context_guard() { - crate::async_context::apply_context_guard(action); - } - if threw { - Err(result.get_nanbox_f64()) - } else { - Ok(()) - } -} - -pub fn leave_resource_scope(async_id: u64) { - if let Err(error) = try_leave_resource_scope(async_id) { - crate::exception::js_throw(error); - } -} - -pub fn run_resource_scope(ids: AsyncResourceIds, completion: impl FnOnce()) { - let _ = run_resource_scope_catching(ids, || { - completion(); - TAG_UNDEFINED_F64 - }); -} - -/// Execute user code inside an existing provider and return its exception only -/// after the provider context and execution-id stacks have been restored. -pub fn try_run_resource_scope( - ids: AsyncResourceIds, - completion: impl FnOnce() -> f64, -) -> Result { - try_enter_resource_scope(ids)?; - let scope = crate::gc::RuntimeHandleScope::new(); - let outcome = crate::exception::js_call_catching(completion); - let (threw, result) = match outcome { - Ok(value) => (false, scope.root_nanbox_f64(value)), - Err(error) => (true, scope.root_nanbox_f64(error)), - }; - let leave = try_leave_resource_scope(ids.async_id); - if let Err(error) = leave { - let error = scope.root_nanbox_f64(error); - return Err(error.get_nanbox_f64()); - } - if threw { - Err(result.get_nanbox_f64()) - } else { - Ok(result.get_nanbox_f64()) - } -} - -pub fn run_resource_scope_catching(ids: AsyncResourceIds, completion: impl FnOnce() -> f64) -> f64 { - match try_run_resource_scope(ids, completion) { - Ok(value) => value, - Err(error) => crate::exception::js_throw(error), - } -} - pub fn enqueue_gc_destroy(async_id: u64) { if async_id != 0 { GC_DESTROY_QUEUE.lock().unwrap().push_back(async_id); diff --git a/crates/perry-runtime/src/async_hooks/scopes.rs b/crates/perry-runtime/src/async_hooks/scopes.rs new file mode 100644 index 0000000000..a2144bfd9f --- /dev/null +++ b/crates/perry-runtime/src/async_hooks/scopes.rs @@ -0,0 +1,149 @@ +//! Exception-safe entry and cleanup for async-resource execution scopes. + +use super::{after, before, destroy, init_resource, AsyncResourceIds, RESOURCES}; + +const TAG_UNDEFINED_F64: f64 = f64::from_bits(crate::value::TAG_UNDEFINED); + +/// Run a synchronous native completion as an observable async-hooks provider. +/// The returned value stays rooted while arbitrary JavaScript hooks run. +pub fn run_provider_completion(type_name: &'static str, completion: impl FnOnce() -> f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = crate::object::js_object_alloc_null_proto(0, 0); + let resource_handle = scope.root_raw_mut_ptr(resource); + let ids = resource_handle.with_mut_ptr::(|resource| { + init_resource( + type_name, + crate::value::js_nanbox_pointer(resource as i64), + true, + ) + }); + let outcome = try_run_resource_scope(ids, completion); + let (threw, result) = match outcome { + Ok(value) => (false, scope.root_nanbox_f64(value)), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + let destroy_outcome = crate::exception::js_call_catching(|| { + destroy(ids.async_id); + TAG_UNDEFINED_F64 + }); + let destroy_error = destroy_outcome + .err() + .map(|error| scope.root_nanbox_f64(error)); + if threw { + crate::exception::js_throw(result.get_nanbox_f64()); + } + if let Some(error) = destroy_error { + crate::exception::js_throw(error.get_nanbox_f64()); + } + result.get_nanbox_f64() +} + +/// Enter an existing provider's captured AsyncLocalStorage and execution-id +/// scope for one native callback phase. +pub fn try_enter_resource_scope(ids: AsyncResourceIds) -> Result<(), f64> { + let context = RESOURCES + .lock() + .unwrap() + .get(&ids.async_id) + .map(|meta| meta.context.clone()) + .unwrap_or_default(); + let previous = crate::async_context::enter_context(&context); + crate::async_context::push_context_guard( + crate::async_context::ContextGuardAction::RestoreSnapshot(previous), + ); + crate::async_context::push_context_guard( + crate::async_context::ContextGuardAction::RestoreExecutionIds, + ); + let outcome = crate::exception::js_call_catching(|| { + before(ids.async_id, ids.trigger_async_id); + TAG_UNDEFINED_F64 + }); + if let Err(error) = outcome { + let scope = crate::gc::RuntimeHandleScope::new(); + let error = scope.root_nanbox_f64(error); + if let Some(action) = crate::async_context::pop_context_guard() { + crate::async_context::apply_context_guard(action); + } + if let Some(action) = crate::async_context::pop_context_guard() { + crate::async_context::apply_context_guard(action); + } + return Err(error.get_nanbox_f64()); + } + Ok(()) +} + +pub fn enter_resource_scope(ids: AsyncResourceIds) { + if let Err(error) = try_enter_resource_scope(ids) { + crate::exception::js_throw(error); + } +} + +/// Leave a provider scope entered by [`enter_resource_scope`]. +pub fn try_leave_resource_scope(async_id: u64) -> Result<(), f64> { + let outcome = crate::exception::js_call_catching(|| { + after(async_id); + TAG_UNDEFINED_F64 + }); + let scope = crate::gc::RuntimeHandleScope::new(); + let (threw, result) = match outcome { + Ok(value) => (false, scope.root_nanbox_f64(value)), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + if let Some(action) = crate::async_context::pop_context_guard() { + if threw { + crate::async_context::apply_context_guard(action); + } + } + if let Some(action) = crate::async_context::pop_context_guard() { + crate::async_context::apply_context_guard(action); + } + if threw { + Err(result.get_nanbox_f64()) + } else { + Ok(()) + } +} + +pub fn leave_resource_scope(async_id: u64) { + if let Err(error) = try_leave_resource_scope(async_id) { + crate::exception::js_throw(error); + } +} + +pub fn run_resource_scope(ids: AsyncResourceIds, completion: impl FnOnce()) { + let _ = run_resource_scope_catching(ids, || { + completion(); + TAG_UNDEFINED_F64 + }); +} + +/// Execute user code inside an existing provider and return its exception only +/// after the provider context and execution-id stacks have been restored. +pub fn try_run_resource_scope( + ids: AsyncResourceIds, + completion: impl FnOnce() -> f64, +) -> Result { + try_enter_resource_scope(ids)?; + let scope = crate::gc::RuntimeHandleScope::new(); + let outcome = crate::exception::js_call_catching(completion); + let (threw, result) = match outcome { + Ok(value) => (false, scope.root_nanbox_f64(value)), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + if let Err(error) = try_leave_resource_scope(ids.async_id) { + let error = scope.root_nanbox_f64(error); + return Err(error.get_nanbox_f64()); + } + if threw { + Err(result.get_nanbox_f64()) + } else { + Ok(result.get_nanbox_f64()) + } +} + +pub fn run_resource_scope_catching(ids: AsyncResourceIds, completion: impl FnOnce() -> f64) -> f64 { + match try_run_resource_scope(ids, completion) { + Ok(value) => value, + Err(error) => crate::exception::js_throw(error), + } +} From 57b75fe8e8902af569f5072659242ca20e1125a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 14:26:35 +0200 Subject: [PATCH 06/15] fix(net): account only accepted socket writes --- crates/perry-ext-net/src/adopt.rs | 1 + crates/perry-ext-net/src/ipc.rs | 2 + crates/perry-ext-net/src/lib.rs | 30 +++--- crates/perry-ext-net/src/lifecycle.rs | 93 +++++++++++++++++-- crates/perry-ext-net/src/server_state.rs | 1 + .../providers/net-write-callbacks.ts | 1 + 6 files changed, 105 insertions(+), 23 deletions(-) diff --git a/crates/perry-ext-net/src/adopt.rs b/crates/perry-ext-net/src/adopt.rs index d3d8f331ad..e972f07c91 100644 --- a/crates/perry-ext-net/src/adopt.rs +++ b/crates/perry-ext-net/src/adopt.rs @@ -53,6 +53,7 @@ pub fn adopt_upgraded_tcp_stream(stream: tokio::net::TcpStream) -> i64 { destroyed: false, bytes_read: 0, bytes_written: 0, + bytes_queued: 0, timeout: None, type_of_service: 0, server_id: None, diff --git a/crates/perry-ext-net/src/ipc.rs b/crates/perry-ext-net/src/ipc.rs index b501471f3a..afd088afdf 100644 --- a/crates/perry-ext-net/src/ipc.rs +++ b/crates/perry-ext-net/src/ipc.rs @@ -44,6 +44,7 @@ fn allocate_socket() -> (i64, mpsc::UnboundedReceiver) { destroyed: false, bytes_read: 0, bytes_written: 0, + bytes_queued: 0, timeout: None, type_of_service: 0, server_id: None, @@ -114,6 +115,7 @@ pub(crate) fn register_accepted_transport( destroyed: false, bytes_read: 0, bytes_written: 0, + bytes_queued: 0, timeout: None, type_of_service: 0, server_id: Some(server_id), diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 548c844021..068c5c1527 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -12,20 +12,10 @@ //! //! # Differences from the perry-stdlib version //! -//! - Uses `perry_ffi::spawn_async` to drive each socket reader / server accept -//! loop cooperatively on Perry's shared multi-thread runtime (the same -//! reactor `crate::common::async_bridge` drives), rather than spinning a -//! throwaway current-thread runtime on a blocking-pool thread per socket. -//! Keepalive comes from `js_ext_net_has_active_handles` (the socket/server is -//! registered synchronously before the spawn), not the blocking-pool -//! active-handle counter. -//! - Uses `perry_ffi::JsClosure` instead of raw `js_closure_call*` extern fns. -//! - Uses `perry_ffi::alloc_buffer` / `BufferHeader` instead of -//! `perry-runtime::buffer::*` directly. -//! - GC root scanner registered via `perry_ffi::gc_register_mutable_root_scanner`. -//! Listeners stored inside the `NET_LISTENERS` map need this — issue #35 -//! pattern — and the mutable visitor lets copied-minor GC rewrite moved -//! closure pointers in place. +//! - Uses `perry_ffi::spawn_async` on Perry's shared runtime, with keepalive +//! provided by `js_ext_net_has_active_handles`. +//! - Uses perry-ffi closures, buffers, and mutable GC root scanning; the latter +//! rewrites listener pointers after a copying minor collection. //! //! TLS is unconditionally compiled in (no `#[cfg(feature = "tls")]` gates //! like perry-stdlib has) — keeping the wrapper crate simple, the deps are @@ -276,6 +266,7 @@ pub(crate) struct SocketState { pub(crate) destroyed: bool, pub(crate) bytes_read: u64, pub(crate) bytes_written: u64, + pub(crate) bytes_queued: u64, pub(crate) timeout: Option, pub(crate) type_of_service: u8, pub(crate) server_id: Option, @@ -301,6 +292,7 @@ impl SocketState { destroyed: false, bytes_read: 0, bytes_written: 0, + bytes_queued: 0, timeout: None, type_of_service: 0, server_id: None, @@ -554,6 +546,7 @@ pub unsafe extern "C" fn js_net_socket_alloc() -> i64 { destroyed: false, bytes_read: 0, bytes_written: 0, + bytes_queued: 0, timeout: None, type_of_service: 0, server_id: None, @@ -1081,6 +1074,7 @@ where destroyed: false, bytes_read: 0, bytes_written: 0, + bytes_queued: 0, timeout: None, type_of_service: 0, server_id: None, @@ -1238,7 +1232,9 @@ pub(crate) async fn run_socket_task( }; match command { Some(SocketCommand::Write(bytes, completion)) => { - if let Err(e) = t.write_all(&bytes).await { + if let Err(e) = + lifecycle::write_socket_bytes(t, id, &bytes).await + { let msg = format!("{}", e); if completion != 0 { push_event(PendingNetEvent::WriteComplete( @@ -1341,7 +1337,9 @@ pub(crate) async fn run_socket_task( buffer_pool::checkin(buf); match cmd { Some(SocketCommand::Write(bytes, completion)) => { - if let Err(e) = t.write_all(&bytes).await { + if let Err(e) = + lifecycle::write_socket_bytes(t, id, &bytes).await + { let msg = format!("{}", e); if completion != 0 { push_event(PendingNetEvent::WriteComplete( diff --git a/crates/perry-ext-net/src/lifecycle.rs b/crates/perry-ext-net/src/lifecycle.rs index 5b1ea5f512..98f16e1590 100644 --- a/crates/perry-ext-net/src/lifecycle.rs +++ b/crates/perry-ext-net/src/lifecycle.rs @@ -22,8 +22,10 @@ use perry_ffi::{alloc_string, nanbox_string_bits, ArrayHeader, JsValue, StringHeader}; use std::collections::HashSet; +use std::io; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; +use tokio::io::AsyncWriteExt; use crate::statics; use crate::string_from_header_i64; @@ -174,14 +176,16 @@ pub unsafe extern "C" fn js_net_socket_get_bytes_read(handle: i64) -> f64 { with_socket(handle, 0u64, |s| s.bytes_read) as f64 } -/// `socket.bytesWritten` — total bytes queued for the socket. +/// `socket.bytesWritten` — bytes dispatched to the transport or still queued. /// /// # Safety /// /// See [`js_net_socket_get_pending`]. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_bytes_written(handle: i64) -> f64 { - with_socket(handle, 0u64, |s| s.bytes_written) as f64 + with_socket(handle, 0u64, |s| { + s.bytes_written.saturating_add(s.bytes_queued) + }) as f64 } /// `socket.timeout` — the value set via `setTimeout(ms)`, or `undefined`. @@ -344,13 +348,14 @@ pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) { fn enqueue_socket_write(handle: i64, bytes: Vec, completion: u64) { let mut sockets = statics::sockets().lock().unwrap(); let failure = if let Some(s) = sockets.get_mut(&handle) { - s.bytes_written = s.bytes_written.saturating_add(bytes.len() as u64); + let byte_len = bytes.len() as u64; if s.cmd_tx .send(crate::SocketCommand::Write(bytes, completion)) .is_err() { Some("Socket write failed") } else { + s.bytes_queued = s.bytes_queued.saturating_add(byte_len); None } } else { @@ -366,6 +371,37 @@ fn enqueue_socket_write(handle: i64, bytes: Vec, completion: u64) { } } +fn record_socket_write_progress(handle: i64, written: usize) { + if written == 0 { + return; + } + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { + let written = written as u64; + socket.bytes_queued = socket.bytes_queued.saturating_sub(written); + socket.bytes_written = socket.bytes_written.saturating_add(written); + } +} + +pub(crate) async fn write_socket_bytes( + transport: &mut crate::Transport, + handle: i64, + bytes: &[u8], +) -> io::Result<()> { + let mut written = 0; + while written < bytes.len() { + let count = transport.write(&bytes[written..]).await?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write socket bytes", + )); + } + written += count; + record_socket_write_progress(handle, count); + } + Ok(()) +} + /// `socket.write(chunk)` under the name the static NATIVE_MODULE_TABLE path /// emits. Delegates to the collision-proof [`js_ext_net_socket_write`] via a /// crate-local call, so even when the bundled stdlib's same-named twin wins the @@ -464,8 +500,10 @@ pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { if let Some(s) = sockets.get_mut(&handle) { if let Some(bytes) = final_bytes { if !bytes.is_empty() { - s.bytes_written = s.bytes_written.saturating_add(bytes.len() as u64); - let _ = s.cmd_tx.send(crate::SocketCommand::Write(bytes, 0)); + let byte_len = bytes.len() as u64; + if s.cmd_tx.send(crate::SocketCommand::Write(bytes, 0)).is_ok() { + s.bytes_queued = s.bytes_queued.saturating_add(byte_len); + } } } let _ = s.cmd_tx.send(crate::SocketCommand::End(0)); @@ -516,8 +554,14 @@ pub unsafe extern "C" fn js_ext_net_socket_end3( let mut sockets = statics::sockets().lock().unwrap(); if let Some(socket) = sockets.get_mut(&handle) { if let Some(bytes) = final_bytes.filter(|bytes| !bytes.is_empty()) { - socket.bytes_written = socket.bytes_written.saturating_add(bytes.len() as u64); - let _ = socket.cmd_tx.send(crate::SocketCommand::Write(bytes, 0)); + let byte_len = bytes.len() as u64; + if socket + .cmd_tx + .send(crate::SocketCommand::Write(bytes, 0)) + .is_ok() + { + socket.bytes_queued = socket.bytes_queued.saturating_add(byte_len); + } } if socket .cmd_tx @@ -1198,4 +1242,39 @@ mod tests { reset_handle(handle); } + + #[test] + fn rejected_write_does_not_increase_bytes_written() { + let handle = -91_238; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + drop(rx); + statics::sockets() + .lock() + .unwrap() + .insert(handle, crate::SocketState::for_test(tx)); + + enqueue_socket_write(handle, vec![1, 2, 3], 0); + assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 0.0); + + statics::sockets().lock().unwrap().remove(&handle); + } + + #[test] + fn bytes_written_includes_queue_then_keeps_only_dispatched_progress_on_close() { + let handle = -91_239; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + statics::sockets() + .lock() + .unwrap() + .insert(handle, crate::SocketState::for_test(tx)); + + enqueue_socket_write(handle, vec![1, 2, 3, 4], 0); + assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 4.0); + record_socket_write_progress(handle, 2); + assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 4.0); + crate::server_state::mark_socket_closed(handle); + assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 2.0); + + statics::sockets().lock().unwrap().remove(&handle); + } } diff --git a/crates/perry-ext-net/src/server_state.rs b/crates/perry-ext-net/src/server_state.rs index e16cebf739..276b88befc 100644 --- a/crates/perry-ext-net/src/server_state.rs +++ b/crates/perry-ext-net/src/server_state.rs @@ -350,6 +350,7 @@ pub(crate) fn mark_socket_closed(socket_id: i64) { return; }; socket.is_open = false; + socket.bytes_queued = 0; let Some(server_id) = socket.server_id.take() else { return; }; diff --git a/test-parity/node-suite/async_hooks/providers/net-write-callbacks.ts b/test-parity/node-suite/async_hooks/providers/net-write-callbacks.ts index 4b1dbcfac8..04b5efdecb 100644 --- a/test-parity/node-suite/async_hooks/providers/net-write-callbacks.ts +++ b/test-parity/node-suite/async_hooks/providers/net-write-callbacks.ts @@ -20,6 +20,7 @@ try { client!.write("payload", () => { console.log("net write callback store:", storage.getStore()); }); + console.log("net write queued bytes:", client!.bytesWritten); client!.end(() => { console.log("net end callback store:", storage.getStore()); }); From 6274ad6698ee5722dd30976764fec2003afe6d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 14:39:52 +0200 Subject: [PATCH 07/15] fix(async_hooks): use scoped runtime handles --- crates/perry-runtime/src/async_hooks.rs | 117 +++++++++++------------- scripts/raw_handle_debt_baseline.txt | 2 +- scripts/raw_handle_debt_files.txt | 2 +- 3 files changed, 55 insertions(+), 66 deletions(-) diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index c3375b09b6..e73c8985e1 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -1328,22 +1328,25 @@ extern "C" fn async_resource_bind_method_trampoline( let scope = crate::gc::RuntimeHandleScope::new(); let args_array = scope.root_raw_const_ptr(crate::value::js_nanbox_get_pointer(rest) as *const ArrayHeader); - let args_len = if args_array.get_raw_const_ptr::().is_null() { - 0 - } else { - js_array_length(args_array.get_raw_const_ptr()) - }; - let callback = if args_len == 0 { - TAG_UNDEFINED_F64 - } else { - crate::array::js_array_get_f64(args_array.get_raw_const_ptr(), 0) - }; + let (callback, this_arg) = args_array.with_const_ptr::(|args_array| { + let args_len = if args_array.is_null() { + 0 + } else { + js_array_length(args_array) + }; + let callback = if args_len == 0 { + TAG_UNDEFINED_F64 + } else { + crate::array::js_array_get_f64(args_array, 0) + }; + let this_arg = if args_len < 2 { + TAG_UNDEFINED_F64 + } else { + crate::array::js_array_get_f64(args_array, 1) + }; + (callback, this_arg) + }); let callback = scope.root_nanbox_f64(callback); - let this_arg = if args_len < 2 { - TAG_UNDEFINED_F64 - } else { - crate::array::js_array_get_f64(args_array.get_raw_const_ptr(), 1) - }; let this_arg = scope.root_nanbox_f64(this_arg); let bound = js_async_resource_bind(handle, callback.get_nanbox_f64(), this_arg.get_nanbox_f64()); @@ -1433,42 +1436,35 @@ pub fn try_async_resource_method_dispatch( }; let arg_handles = scope.root_nanbox_f64_slice(&raw_args); let receiver = scope.root_raw_mut_ptr(receiver as *mut ObjectHeader); - let handle = resolve_async_resource_handle(receiver.get_raw_mut_ptr::() as i64)?; - let handle = scope.root_raw_const_ptr(handle as *const AsyncResourceHandle); - let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + let handle = receiver.with_mut_ptr::(|receiver| { + resolve_async_resource_handle(receiver as i64) + })?; Some(match method_name { - "asyncId" => { - js_async_resource_async_id(handle.get_raw_const_ptr::() as i64) - } - "triggerAsyncId" => js_async_resource_trigger_async_id( - handle.get_raw_const_ptr::() as i64, - ), + "asyncId" => js_async_resource_async_id(handle), + "triggerAsyncId" => js_async_resource_trigger_async_id(handle), "emitDestroy" => { - js_async_resource_emit_destroy(handle.get_raw_const_ptr::() as i64); - crate::value::js_nanbox_pointer(receiver.get_raw_mut_ptr::() as i64) + let (_, receiver) = + receiver.across_mut::(|| js_async_resource_emit_destroy(handle)); + crate::value::js_nanbox_pointer(receiver as i64) } "runInAsyncScope" => { // runInAsyncScope(fn[, thisArg, ...args]) - let callback = args.first().copied().unwrap_or(TAG_UNDEFINED_F64); - let this_arg = args.get(1).copied().unwrap_or(TAG_UNDEFINED_F64); + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); let rest = if args.len() > 2 { &args[2..] } else { &[] }; let args_array = pack_rest_args_array(rest); - js_async_resource_run_in_async_scope( - handle.get_raw_const_ptr::() as i64, - callback, - this_arg, - args_array, - ) + // Packing the rest array may collect, so refresh callback and + // thisArg from their roots before dispatching the call. + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + let callback = args.first().copied().unwrap_or(TAG_UNDEFINED_F64); + let this_arg = args.get(1).copied().unwrap_or(TAG_UNDEFINED_F64); + js_async_resource_run_in_async_scope(handle, callback, this_arg, args_array) } "bind" => { // bind(fn[, thisArg]) + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); let callback = args.first().copied().unwrap_or(TAG_UNDEFINED_F64); let this_arg = args.get(1).copied().unwrap_or(TAG_UNDEFINED_F64); - let bound = js_async_resource_bind( - handle.get_raw_const_ptr::() as i64, - callback, - this_arg, - ); + let bound = js_async_resource_bind(handle, callback, this_arg); if bound == 0 { TAG_UNDEFINED_F64 } else { @@ -1536,15 +1532,15 @@ pub extern "C" fn js_async_resource_run_in_async_scope( let callback_handle = scope.root_nanbox_f64(callback_value); let this_arg_handle = scope.root_nanbox_f64(this_arg); let args_array_handle = scope.root_raw_const_ptr(args_array as *const ArrayHeader); - let receiver = receiver_handle.get_raw_mut_ptr::() as i64; - let Some(handle) = resolve_async_resource_handle(receiver) else { + let Some(handle) = receiver_handle + .with_mut_ptr::(|receiver| resolve_async_resource_handle(receiver as i64)) + else { return TAG_UNDEFINED_F64; }; - let handle = scope.root_raw_const_ptr(handle as *const AsyncResourceHandle); if !is_callable_value(callback_handle.get_nanbox_f64()) { throw_apply_not_function(callback_handle.get_nanbox_f64()); } - let ids = unsafe { (*handle.get_raw_const_ptr::()).ids }; + let ids = unsafe { (*(handle as *const AsyncResourceHandle)).ids }; let rebound_bits = crate::closure::clone_closure_rebind_this( callback_handle.get_nanbox_f64().to_bits(), this_arg_handle.get_nanbox_f64(), @@ -1559,19 +1555,17 @@ pub extern "C" fn js_async_resource_run_in_async_scope( this_arg_handle.get_nanbox_f64(), )); let callback_outcome = crate::exception::js_call_catching(|| { - if args_array_handle - .get_raw_const_ptr::() - .is_null() - { - unsafe { js_closure_call_array(callback as i64, ptr::null(), 0) } - } else { - let arr = args_array_handle.get_raw_const_ptr::(); - let len = js_array_length(arr) as i64; - let data = unsafe { - (arr as *const u8).add(std::mem::size_of::()) as *const f64 - }; - unsafe { js_closure_call_array(callback as i64, data, len) } - } + args_array_handle.with_const_ptr::(|arr| { + if arr.is_null() { + unsafe { js_closure_call_array(callback as i64, ptr::null(), 0) } + } else { + let len = js_array_length(arr) as i64; + let data = unsafe { + (arr as *const u8).add(std::mem::size_of::()) as *const f64 + }; + unsafe { js_closure_call_array(callback as i64, data, len) } + } + }) }); crate::object::js_implicit_this_set(previous_this.get_nanbox_f64()); match callback_outcome { @@ -1629,23 +1623,18 @@ pub extern "C" fn js_async_resource_bind(handle: i64, callback_value: f64, this_ let callback_handle = scope.root_nanbox_f64(callback_value); let this_arg_handle = scope.root_nanbox_f64(this_arg); validate_bind_callback(callback_handle.get_nanbox_f64()); - let Some(handle) = - resolve_async_resource_handle(receiver_handle.get_raw_mut_ptr::() as i64) + let Some(handle) = receiver_handle + .with_mut_ptr::(|receiver| resolve_async_resource_handle(receiver as i64)) else { return 0; }; - let handle = scope.root_raw_const_ptr(handle as *const AsyncResourceHandle); register_bind_trampoline_once(); let closure = js_closure_alloc(async_resource_bind_trampoline as *const u8, 3); if closure.is_null() { return 0; } let closure_handle = scope.root_raw_mut_ptr(closure); - js_closure_set_capture_ptr( - closure_handle.get_raw_mut_ptr(), - 0, - handle.get_raw_const_ptr::() as i64, - ); + js_closure_set_capture_ptr(closure_handle.get_raw_mut_ptr(), 0, handle); js_closure_set_capture_f64( closure_handle.get_raw_mut_ptr(), 1, diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index e2cab550c5..a0394c20d6 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -950 +949 diff --git a/scripts/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt index e1d0738d7b..fdb66ef785 100644 --- a/scripts/raw_handle_debt_files.txt +++ b/scripts/raw_handle_debt_files.txt @@ -53,7 +53,7 @@ 31 crates/perry-runtime/src/array/iterator.rs 4 crates/perry-runtime/src/array/push_pop.rs 14 crates/perry-runtime/src/array/sort.rs -13 crates/perry-runtime/src/async_hooks.rs +12 crates/perry-runtime/src/async_hooks.rs 5 crates/perry-runtime/src/atomics.rs 7 crates/perry-runtime/src/builtins/console.rs 12 crates/perry-runtime/src/builtins/globals.rs From 4b63f366bda0122dfa1cde354cbbb758015c40f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 14:48:38 +0200 Subject: [PATCH 08/15] chore(changelog): key async hooks fragment to PR --- ...815-async-hooks-lifecycle.md => 8825-async-hooks-lifecycle.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{8815-async-hooks-lifecycle.md => 8825-async-hooks-lifecycle.md} (100%) diff --git a/changelog.d/8815-async-hooks-lifecycle.md b/changelog.d/8825-async-hooks-lifecycle.md similarity index 100% rename from changelog.d/8815-async-hooks-lifecycle.md rename to changelog.d/8825-async-hooks-lifecycle.md From d7dda3fde32e9cc82a24843b8d458496467c4a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 15:31:41 +0200 Subject: [PATCH 09/15] fix(async_hooks): preserve scoped event coercion --- crates/perry-ext-events/src/lib.rs | 46 ++++++++++++++----- .../perry-runtime/src/async_hooks/scopes.rs | 3 ++ .../src/async_hooks/test_support.rs | 14 ++++++ .../integrations/events-emitter.ts | 30 +++++++++++- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/crates/perry-ext-events/src/lib.rs b/crates/perry-ext-events/src/lib.rs index 169a35c312..bd35278bbf 100644 --- a/crates/perry-ext-events/src/lib.rs +++ b/crates/perry-ext-events/src/lib.rs @@ -24,6 +24,7 @@ use perry_ffi::{ js_array_set, js_object_alloc_with_shape, js_object_set_field, nanbox_string_bits, read_string, throw_with_code, ArrayHeader, ErrorKind, Handle, JsPromise, JsString, JsValue, ObjectHeader, Promise, RawClosureHeader, StringHeader, TransientRootScope, TransientRootedAddr, + TransientRootedNanbox, }; use std::collections::{HashMap, HashSet}; use std::ffi::c_void; @@ -696,6 +697,15 @@ unsafe fn event_name_from_bits(event_bits: i64) -> Option { string_from_header(rendered as *const StringHeader) } +fn event_value_from_bits(event_bits: i64) -> f64 { + let raw = event_bits as u64; + if (0x10000..MAX_HEAP_POINTER).contains(&raw) && (raw & TAG_MASK) == 0 { + f64::from_bits(nanbox_string_bits(raw as *mut StringHeader)) + } else { + f64::from_bits(raw) + } +} + fn event_bits_from_string_ptr(ptr: *const StringHeader) -> i64 { f64::from_bits(nanbox_string_bits(ptr as *mut StringHeader)).to_bits() as i64 } @@ -1297,17 +1307,18 @@ pub unsafe extern "C" fn js_event_emitter_emit( args_ptr: *mut ArrayHeader, ) -> f64 { let roots = TransientRootScope::enter(); + let event_value = roots.root_nanbox(event_value_from_bits(event_bits)); let args_ptr = roots.root_addr(args_ptr as i64); - let Some(event_name) = event_name_from_bits(event_bits) else { - return f64::from_bits(0x7FFC_0000_0000_0003); - }; let async_id = event_emitter_async_id(handle); if async_id == 0 { + let Some(event_name) = event_name_from_bits(event_value.get().to_bits() as i64) else { + return f64::from_bits(0x7FFC_0000_0000_0003); + }; return js_event_emitter_emit_impl(handle, &event_name, args_ptr.get() as *mut ArrayHeader); } let mut call = EventEmitterEmitCall { handle, - event_name, + event_value, args_ptr, }; js_async_hooks_provider_run_catching( @@ -1319,15 +1330,18 @@ pub unsafe extern "C" fn js_event_emitter_emit( struct EventEmitterEmitCall { handle: Handle, - event_name: String, + event_value: TransientRootedNanbox, args_ptr: TransientRootedAddr, } unsafe extern "C" fn event_emitter_emit_thunk(data: *mut std::ffi::c_void) -> f64 { let call = &mut *(data as *mut EventEmitterEmitCall); + let Some(event_name) = event_name_from_bits(call.event_value.get().to_bits() as i64) else { + return f64::from_bits(0x7FFC_0000_0000_0003); + }; js_event_emitter_emit_impl( call.handle, - &call.event_name, + &event_name, call.args_ptr.get() as *mut ArrayHeader, ) } @@ -1415,14 +1429,19 @@ unsafe fn js_event_emitter_emit_impl( /// `event_name_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) -> f64 { - let Some(event_name) = event_name_from_bits(event_bits) else { - return f64::from_bits(0x7FFC_0000_0000_0003); - }; + let roots = TransientRootScope::enter(); + let event_value = roots.root_nanbox(event_value_from_bits(event_bits)); let async_id = event_emitter_async_id(handle); if async_id == 0 { + let Some(event_name) = event_name_from_bits(event_value.get().to_bits() as i64) else { + return f64::from_bits(0x7FFC_0000_0000_0003); + }; return js_event_emitter_emit0_impl(handle, &event_name); } - let mut call = EventEmitterEmit0Call { handle, event_name }; + let mut call = EventEmitterEmit0Call { + handle, + event_value, + }; js_async_hooks_provider_run_catching( async_id, event_emitter_emit0_thunk, @@ -1432,12 +1451,15 @@ pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) struct EventEmitterEmit0Call { handle: Handle, - event_name: String, + event_value: TransientRootedNanbox, } unsafe extern "C" fn event_emitter_emit0_thunk(data: *mut std::ffi::c_void) -> f64 { let call = &mut *(data as *mut EventEmitterEmit0Call); - js_event_emitter_emit0_impl(call.handle, &call.event_name) + let Some(event_name) = event_name_from_bits(call.event_value.get().to_bits() as i64) else { + return f64::from_bits(0x7FFC_0000_0000_0003); + }; + js_event_emitter_emit0_impl(call.handle, &event_name) } unsafe fn js_event_emitter_emit0_impl(handle: Handle, event_name: &str) -> f64 { diff --git a/crates/perry-runtime/src/async_hooks/scopes.rs b/crates/perry-runtime/src/async_hooks/scopes.rs index a2144bfd9f..f5e046dd6c 100644 --- a/crates/perry-runtime/src/async_hooks/scopes.rs +++ b/crates/perry-runtime/src/async_hooks/scopes.rs @@ -131,6 +131,9 @@ pub fn try_run_resource_scope( Err(error) => (true, scope.root_nanbox_f64(error)), }; if let Err(error) = try_leave_resource_scope(ids.async_id) { + if threw { + return Err(result.get_nanbox_f64()); + } let error = scope.root_nanbox_f64(error); return Err(error.get_nanbox_f64()); } diff --git a/crates/perry-runtime/src/async_hooks/test_support.rs b/crates/perry-runtime/src/async_hooks/test_support.rs index ce0ec4a56f..18efb29c0a 100644 --- a/crates/perry-runtime/src/async_hooks/test_support.rs +++ b/crates/perry-runtime/src/async_hooks/test_support.rs @@ -131,6 +131,20 @@ mod tests { reset_for_tests(); } + #[test] + fn resource_scope_prefers_completion_error_over_after_error() { + reset_for_tests(); + let ids = init_resource("double-faulting-scope", TAG_UNDEFINED_F64, true); + enable_throwing_lifecycle_hook(false); + + let outcome = try_run_resource_scope(ids, || crate::exception::js_throw(41.0)); + + assert_eq!(outcome.unwrap_err().to_bits(), 41.0f64.to_bits()); + assert_eq!(execution_async_id_u64(), 0); + assert!(EXECUTION_STACK.with(|stack| stack.borrow().is_empty())); + reset_for_tests(); + } + #[test] fn track_promises_filters_hooks_and_activity() { reset_for_tests(); diff --git a/test-parity/node-suite/async_hooks/integrations/events-emitter.ts b/test-parity/node-suite/async_hooks/integrations/events-emitter.ts index ff84b7681e..a55bc716e5 100644 --- a/test-parity/node-suite/async_hooks/integrations/events-emitter.ts +++ b/test-parity/node-suite/async_hooks/integrations/events-emitter.ts @@ -1,5 +1,5 @@ -import { EventEmitter } from "node:events"; -import { AsyncLocalStorage } from "node:async_hooks"; +import { EventEmitter, EventEmitterAsyncResource } from "node:events"; +import { AsyncLocalStorage, executionAsyncId } from "node:async_hooks"; const storage = new AsyncLocalStorage(); @@ -45,3 +45,29 @@ conversionEmitter.on("converted", (value) => { }); conversionEmitter.emit(convertedName as unknown as string, "value"); console.log("event name conversion:", eventNameConversions, convertedValue); + +const scopedConversionEmitter = storage.run( + "conversion-resource", + () => new EventEmitterAsyncResource({ name: "ConversionEmitter" }), +); +let scopedConversions = 0; +let conversionAsyncIdsMatch = true; +const conversionStores: Array = []; +const scopedName = { + toString() { + scopedConversions += 1; + conversionAsyncIdsMatch &&= + executionAsyncId() === scopedConversionEmitter.asyncId; + conversionStores.push(storage.getStore()); + return "converted"; + }, +}; +scopedConversionEmitter.on("converted", () => {}); +storage.run("conversion-caller", () => { + scopedConversionEmitter.emit(scopedName as unknown as string); + scopedConversionEmitter.emit(scopedName as unknown as string, "value"); +}); +console.log("scoped event name conversion:", scopedConversions); +console.log("scoped event name async id:", conversionAsyncIdsMatch); +console.log("scoped event name store:", conversionStores.join(",")); +scopedConversionEmitter.emitDestroy(); From 2d9232e34199da10da28db5c82dd7665f50f609d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 15:52:27 +0200 Subject: [PATCH 10/15] refactor(events): split scoped emit thunks --- crates/perry-ext-events/src/emit_scope.rs | 32 +++++++++++++++++ crates/perry-ext-events/src/lib.rs | 44 ++++++----------------- 2 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 crates/perry-ext-events/src/emit_scope.rs diff --git a/crates/perry-ext-events/src/emit_scope.rs b/crates/perry-ext-events/src/emit_scope.rs new file mode 100644 index 0000000000..ddee1fb1a3 --- /dev/null +++ b/crates/perry-ext-events/src/emit_scope.rs @@ -0,0 +1,32 @@ +use super::*; + +pub(super) struct EventEmitterEmitCall { + pub(super) handle: Handle, + pub(super) event_value: TransientRootedNanbox, + pub(super) args_ptr: TransientRootedAddr, +} + +pub(super) unsafe extern "C" fn event_emitter_emit_thunk(data: *mut c_void) -> f64 { + let call = &mut *(data as *mut EventEmitterEmitCall); + let Some(event_name) = event_name_from_bits(call.event_value.get().to_bits() as i64) else { + return f64::from_bits(0x7FFC_0000_0000_0003); + }; + js_event_emitter_emit_impl( + call.handle, + &event_name, + call.args_ptr.get() as *mut ArrayHeader, + ) +} + +pub(super) struct EventEmitterEmit0Call { + pub(super) handle: Handle, + pub(super) event_value: TransientRootedNanbox, +} + +pub(super) unsafe extern "C" fn event_emitter_emit0_thunk(data: *mut c_void) -> f64 { + let call = &mut *(data as *mut EventEmitterEmit0Call); + let Some(event_name) = event_name_from_bits(call.event_value.get().to_bits() as i64) else { + return f64::from_bits(0x7FFC_0000_0000_0003); + }; + js_event_emitter_emit0_impl(call.handle, &event_name) +} diff --git a/crates/perry-ext-events/src/lib.rs b/crates/perry-ext-events/src/lib.rs index bd35278bbf..e29413be7b 100644 --- a/crates/perry-ext-events/src/lib.rs +++ b/crates/perry-ext-events/src/lib.rs @@ -32,6 +32,11 @@ use std::sync::{Mutex, MutexGuard, Once, OnceLock}; mod error_monitor; use error_monitor::dispatch_error_monitor; +mod emit_scope; +use emit_scope::{ + event_emitter_emit0_thunk, event_emitter_emit_thunk, EventEmitterEmit0Call, + EventEmitterEmitCall, +}; mod max_listeners; mod messages; mod module_helpers; @@ -687,9 +692,13 @@ unsafe fn string_from_header(ptr: *const StringHeader) -> Option { read_string(handle).map(String::from) } +fn is_raw_string_header_bits(raw: u64) -> bool { + (0x10000..MAX_HEAP_POINTER).contains(&raw) && (raw & TAG_MASK) == 0 +} + unsafe fn event_name_from_bits(event_bits: i64) -> Option { let raw = event_bits as u64; - if (0x10000..MAX_HEAP_POINTER).contains(&raw) && (raw & TAG_MASK) == 0 { + if is_raw_string_header_bits(raw) { return string_from_header(raw as *const StringHeader); } @@ -699,7 +708,7 @@ unsafe fn event_name_from_bits(event_bits: i64) -> Option { fn event_value_from_bits(event_bits: i64) -> f64 { let raw = event_bits as u64; - if (0x10000..MAX_HEAP_POINTER).contains(&raw) && (raw & TAG_MASK) == 0 { + if is_raw_string_header_bits(raw) { f64::from_bits(nanbox_string_bits(raw as *mut StringHeader)) } else { f64::from_bits(raw) @@ -1328,24 +1337,6 @@ pub unsafe extern "C" fn js_event_emitter_emit( ) } -struct EventEmitterEmitCall { - handle: Handle, - event_value: TransientRootedNanbox, - args_ptr: TransientRootedAddr, -} - -unsafe extern "C" fn event_emitter_emit_thunk(data: *mut std::ffi::c_void) -> f64 { - let call = &mut *(data as *mut EventEmitterEmitCall); - let Some(event_name) = event_name_from_bits(call.event_value.get().to_bits() as i64) else { - return f64::from_bits(0x7FFC_0000_0000_0003); - }; - js_event_emitter_emit_impl( - call.handle, - &event_name, - call.args_ptr.get() as *mut ArrayHeader, - ) -} - unsafe fn js_event_emitter_emit_impl( handle: Handle, event_name: &str, @@ -1449,19 +1440,6 @@ pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) ) } -struct EventEmitterEmit0Call { - handle: Handle, - event_value: TransientRootedNanbox, -} - -unsafe extern "C" fn event_emitter_emit0_thunk(data: *mut std::ffi::c_void) -> f64 { - let call = &mut *(data as *mut EventEmitterEmit0Call); - let Some(event_name) = event_name_from_bits(call.event_value.get().to_bits() as i64) else { - return f64::from_bits(0x7FFC_0000_0000_0003); - }; - js_event_emitter_emit0_impl(call.handle, &event_name) -} - unsafe fn js_event_emitter_emit0_impl(handle: Handle, event_name: &str) -> f64 { const TAG_FALSE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0003); const TAG_TRUE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0004); From 2fe27f723013eca2b2a626bfdb2b8ec0171a3227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 16:37:43 +0200 Subject: [PATCH 11/15] test(async_hooks): classify forced GC trigger --- scripts/gc_runtime_root_holders.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 8a3237de38..41757c5f6a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -131,6 +131,12 @@ "verdict": "not_a_gc_pointer", "why": "Monotonic counter of live async-resource handles. Holds no address at all; the resource objects live in RESOURCES, which scan_async_hooks_roots_mut visits." }, + { + "file": "crates/perry-runtime/src/async_hooks.rs", + "name": "TEST_FORCE_RESOLVE_GC", + "verdict": "test_only", + "why": "#[cfg(test)] AtomicUsize one-shot flag that asks resolve_async_resource_handle to force a collection; stores only 0 or 1 and is absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/child_process/reactor.rs", "name": "CP_NEXT_LIVE_ID", From 492490f841eece9ea77d40fcd38f79661262e92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 18:37:37 +0200 Subject: [PATCH 12/15] perf(map): specialize declared Map get dispatch --- changelog.d/0000-declared-map-get-dispatch.md | 4 + .../src/expr/readonly_collection_tests.rs | 118 +++++++++++++++++- .../src/lower_call/property_get/map_set.rs | 20 ++- .../src/runtime_decls/strings.rs | 1 + crates/perry-codegen/src/type_analysis.rs | 8 +- .../src/type_analysis/strings.rs | 64 ++++++++-- crates/perry-runtime/src/map.rs | 39 ++++++ .../tests/declared_map_branded_dispatch.rs | 101 +++++++++++++++ 8 files changed, 341 insertions(+), 14 deletions(-) create mode 100644 changelog.d/0000-declared-map-get-dispatch.md create mode 100644 crates/perry/tests/declared_map_branded_dispatch.rs diff --git a/changelog.d/0000-declared-map-get-dispatch.md b/changelog.d/0000-declared-map-get-dispatch.md new file mode 100644 index 0000000000..17d29a947d --- /dev/null +++ b/changelog.d/0000-declared-map-get-dispatch.md @@ -0,0 +1,4 @@ +Speed up declared `Map.get` and `ReadonlyMap.get` calls that pass through +nested interface or object fields. Genuine native Maps now bypass generic +method dispatch, while structural values, subclasses, proxies, primitives, +and nullish receivers retain ordinary JavaScript behavior on a brand miss. diff --git a/crates/perry-codegen/src/expr/readonly_collection_tests.rs b/crates/perry-codegen/src/expr/readonly_collection_tests.rs index fcd8fff304..c18f6f3b80 100644 --- a/crates/perry-codegen/src/expr/readonly_collection_tests.rs +++ b/crates/perry-codegen/src/expr/readonly_collection_tests.rs @@ -1,6 +1,8 @@ use crate::{compile_module, CompileOptions, ImportedClass}; use perry_hir::types::Type; -use perry_hir::{Class, ClassField, Expr, Function, Module, Param, Stmt}; +use perry_hir::{ + Class, ClassField, Expr, Function, Interface, InterfaceProperty, Module, Param, Stmt, +}; fn number_param(id: u32, name: &str) -> Param { Param { @@ -223,6 +225,105 @@ fn compile_imported_has_ir() -> String { .expect("LLVM IR is UTF-8") } +fn compile_nested_map_get_ir() -> String { + let mut module = Module::new("command_executor.ts"); + module.interfaces.push(Interface { + id: 1, + name: "CommandExecutorContext".to_string(), + type_params: Vec::new(), + extends: Vec::new(), + properties: vec![InterfaceProperty { + name: "entityToArchetype".to_string(), + ty: Type::Generic { + base: "Map".to_string(), + type_args: vec![Type::Number, Type::Number], + }, + optional: false, + readonly: false, + }], + methods: Vec::new(), + is_exported: false, + }); + module.classes.push(Class { + id: 2, + name: "CommandExecutor".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ClassField { + name: "ctx".to_string(), + key_expr: None, + ty: Type::Named("CommandExecutorContext".to_string()), + init: None, + is_private: false, + is_readonly: true, + decorators: Vec::new(), + }], + constructor: None, + methods: vec![Function { + id: 3, + name: "lookup".to_string(), + type_params: Vec::new(), + params: vec![number_param(1, "entityId")], + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "ctx".to_string(), + byte_offset: 0, + }), + property: "entityToArchetype".to_string(), + byte_offset: 0, + }), + property: "get".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(1)], + type_args: Vec::new(), + byte_offset: 0, + }))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + 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, + }); + + String::from_utf8( + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ) + .expect("nested declared Map.get compiles"), + ) + .expect("LLVM IR is UTF-8") +} + fn method_ir<'a>(ir: &'a str, owner: &str, method: &str) -> &'a str { let suffix = format!("__{owner}__{method}("); let suffix_start = ir.find(&suffix).expect("requested method is present"); @@ -280,3 +381,18 @@ fn imported_class_readonly_set_field_uses_branded_fast_path() { "cross-module field metadata must not force native Sets through generic dispatch:\n{method_ir}" ); } + +#[test] +fn nested_interface_map_field_get_uses_branded_dispatch() { + let ir = compile_nested_map_get_ir(); + let method_ir = method_ir(&ir, "CommandExecutor", "lookup"); + + assert!( + method_ir.contains("call double @js_declared_map_get("), + "a Map reached through a nested interface field must retain a branded dispatch candidate:\n{method_ir}" + ); + assert!( + !method_ir.contains("call double @js_native_call_method_by_id("), + "a genuine native Map must not enter generic method dispatch at this site:\n{method_ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/property_get/map_set.rs b/crates/perry-codegen/src/lower_call/property_get/map_set.rs index 6d90dee044..68076e8e34 100644 --- a/crates/perry-codegen/src/lower_call/property_get/map_set.rs +++ b/crates/perry-codegen/src/lower_call/property_get/map_set.rs @@ -30,7 +30,7 @@ use crate::expr::{lower_expr, unbox_to_i64, FnCtx}; use crate::nanbox::double_literal; use crate::rooting; use crate::type_analysis::{ - is_map_expr, is_readonly_set_expr, is_set_expr, is_url_search_params_expr, + is_declared_map_expr, is_map_expr, is_readonly_set_expr, is_set_expr, is_url_search_params_expr, }; use crate::types::{DOUBLE, I64}; @@ -59,7 +59,23 @@ pub(crate) fn try_lower_map_set_methods( ))) }); } - if is_map_expr(ctx, object) { + let is_native_map = is_map_expr(ctx, object); + // A nested interface/object field can retain its `Map` or `ReadonlyMap` + // declaration after the stronger native-layout proof is lost. Avoid the + // full property/method dispatcher for a genuine native Map, but preserve + // structural and subclass behavior through the runtime brand miss. + if !is_native_map && is_declared_map_expr(ctx, object) && property == "get" && args.len() == 1 { + return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { + let receiver = vals[0].clone(); + let key = vals[1].clone(); + Ok(Some(ctx.block().call( + DOUBLE, + "js_declared_map_get", + &[(DOUBLE, &receiver), (DOUBLE, &key)], + ))) + }); + } + if is_native_map { match property { "set" if args.len() == 2 => { // #6970: each finished operand is live in an SSA register diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 2a90e2db99..17aeb903e9 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -400,6 +400,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_map_set_string_string", I64, &[I64, I64, I64]); module.declare_function("js_map_set_number_key", I64, &[I64, DOUBLE, DOUBLE]); module.declare_function("js_map_get", DOUBLE, &[I64, DOUBLE]); + module.declare_function("js_declared_map_get", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_map_get_string_key", DOUBLE, &[I64, I64]); module.declare_function("js_map_get_number_key", DOUBLE, &[I64, DOUBLE]); module.declare_function("js_map_has", I32, &[I64, DOUBLE]); diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index 2212a195c2..fc0e7a4c50 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -59,10 +59,10 @@ pub(crate) use refine::{ proven_type_from_init, refine_type_from_init, }; pub(crate) use strings::{ - class_name_extends_url_search_params, is_declared_string_expr, is_definitely_string_expr, - is_map_expr, is_readonly_set_expr, is_set_expr, is_string_expr, is_url_search_params_expr, - is_url_search_params_subclass_expr, map_static_type_args, set_static_type_args, - string_proof_is_declared_only, string_value_is_runtime_guaranteed, + class_name_extends_url_search_params, is_declared_map_expr, is_declared_string_expr, + is_definitely_string_expr, is_map_expr, is_readonly_set_expr, is_set_expr, is_string_expr, + is_url_search_params_expr, is_url_search_params_subclass_expr, map_static_type_args, + set_static_type_args, string_proof_is_declared_only, string_value_is_runtime_guaranteed, }; #[cfg(test)] diff --git a/crates/perry-codegen/src/type_analysis/strings.rs b/crates/perry-codegen/src/type_analysis/strings.rs index 13231d7db8..969d14472a 100644 --- a/crates/perry-codegen/src/type_analysis/strings.rs +++ b/crates/perry-codegen/src/type_analysis/strings.rs @@ -60,6 +60,33 @@ pub(crate) fn is_readonly_set_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } +/// True when a declared type says that the expression is a `Map` or +/// `ReadonlyMap`, but does not by itself prove Perry's native Map +/// layout. +/// +/// In particular this retains a useful candidate through nested structural +/// fields such as `this.ctx.entityToArchetype`. Callers must use a branded +/// runtime operation with ordinary method dispatch on a brand miss. +pub(crate) fn is_declared_map_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { + match e { + Expr::LocalGet(id) => ctx.local_type_hint(id).is_some_and(type_is_declared_map), + Expr::PropertyGet { + object, property, .. + } => static_type_of(ctx, object).is_some_and(|owner_ty| { + type_may_declare_collection_field(ctx, &owner_ty, property, type_is_declared_map, 0) + }), + _ => false, + } +} + +#[inline] +fn type_is_declared_map(ty: &HirType) -> bool { + matches!( + ty, + HirType::Generic { base, .. } if base == "Map" || base == "ReadonlyMap" + ) +} + #[inline] fn type_is_readonly_set(ty: &HirType) -> bool { matches!(ty, HirType::Generic { base, .. } if base == "ReadonlySet") @@ -76,6 +103,16 @@ fn type_may_declare_readonly_set_field( owner_ty: &HirType, property: &str, depth: usize, +) -> bool { + type_may_declare_collection_field(ctx, owner_ty, property, type_is_readonly_set, depth) +} + +fn type_may_declare_collection_field( + ctx: &FnCtx<'_>, + owner_ty: &HirType, + property: &str, + matches_collection: fn(&HirType) -> bool, + depth: usize, ) -> bool { if depth > 32 { return false; @@ -83,28 +120,41 @@ fn type_may_declare_readonly_set_field( match owner_ty { HirType::Union(variants) => variants.iter().any(|variant| { !matches!(variant, HirType::Null | HirType::Void | HirType::Never) - && type_may_declare_readonly_set_field(ctx, variant, property, depth + 1) + && type_may_declare_collection_field( + ctx, + variant, + property, + matches_collection, + depth + 1, + ) }), HirType::Named(name) | HirType::Generic { base: name, .. } => { if let Some(class) = ctx.classes.get(name) { if let Some(field) = class.fields.iter().find(|field| field.name == property) { - return type_is_readonly_set(&field.ty); + return matches_collection(&field.ty); } if let Some(parent) = class.extends_name.as_deref() { - return type_may_declare_readonly_set_field( + return type_may_declare_collection_field( ctx, &HirType::Named(parent.to_string()), property, + matches_collection, depth + 1, ); } } if let Some(iface) = ctx.interfaces.get(name) { if let Some(field) = iface.properties.iter().find(|field| field.name == property) { - return type_is_readonly_set(&field.ty); + return matches_collection(&field.ty); } if iface.extends.iter().any(|parent| { - type_may_declare_readonly_set_field(ctx, parent, property, depth + 1) + type_may_declare_collection_field( + ctx, + parent, + property, + matches_collection, + depth + 1, + ) }) { return true; } @@ -115,13 +165,13 @@ fn type_may_declare_readonly_set_field( if object .properties .get(property) - .is_some_and(|field| type_is_readonly_set(&field.ty)) + .is_some_and(|field| matches_collection(&field.ty)) ) } HirType::Object(object) => object .properties .get(property) - .is_some_and(|field| type_is_readonly_set(&field.ty)), + .is_some_and(|field| matches_collection(&field.ty)), _ => false, } } diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 84352f507b..d94075f83b 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -1637,6 +1637,11 @@ pub extern "C" fn js_map_get(map: *const MapHeader, key: f64) -> f64 { if map.is_null() { return f64::from_bits(TAG_UNDEFINED); } + map_get_resolved(map, key) +} + +#[inline(always)] +fn map_get_resolved(map: *const MapHeader, key: f64) -> f64 { let key = normalize_zero(key); unsafe { let idx = find_key_index(map, key); @@ -1650,6 +1655,40 @@ pub extern "C" fn js_map_get(map: *const MapHeader, key: f64) -> f64 { } } +/// Fast `Map.get`/`ReadonlyMap.get` for a declared structural receiver. +/// +/// A TypeScript collection annotation does not prove Perry's native layout. +/// Genuine `GC_TYPE_MAP` receivers bypass generic property/method dispatch; +/// structural objects, proxies, subclasses, primitives, and nullish values +/// retain ordinary `receiver.get(key)` behavior on a brand miss. +#[no_mangle] +pub unsafe extern "C-unwind" fn js_declared_map_get(receiver: f64, key: f64) -> f64 { + let receiver_value = crate::value::JSValue::from_bits(receiver.to_bits()); + if receiver_value.is_pointer() { + let raw = receiver_value.as_pointer::(); + if matches!( + crate::value::addr_class::try_read_gc_header(raw as usize), + Some(header) if header.obj_type == crate::gc::GC_TYPE_MAP + ) { + return map_get_resolved(raw, key); + } + } + + // Generic dispatch can allocate and re-enter generated code. Keep both + // operands rooted and refresh them before crossing that boundary. + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_handle = scope.root_nanbox_f64(receiver); + let key_handle = scope.root_nanbox_f64(key); + let refreshed_key = key_handle.get_nanbox_f64(); + crate::object::js_native_call_method( + receiver_handle.get_nanbox_f64(), + b"get".as_ptr() as *const i8, + 3, + &refreshed_key, + 1, + ) +} + #[no_mangle] pub extern "C" fn js_map_get_number_key(map: *const MapHeader, key: f64) -> f64 { let Some(key) = normalize_number_key_from_boxed(key) else { diff --git a/crates/perry/tests/declared_map_branded_dispatch.rs b/crates/perry/tests/declared_map_branded_dispatch.rs new file mode 100644 index 0000000000..c8bccb07c8 --- /dev/null +++ b/crates/perry/tests/declared_map_branded_dispatch.rs @@ -0,0 +1,101 @@ +//! Executable semantics for guarded `Map.get` / `ReadonlyMap.get` dispatch. +//! Native Maps bypass generic method lookup while structural values and Map +//! subclasses retain ordinary JavaScript behavior. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn native_structural_subclass_and_nullish_receivers_keep_get_semantics() { + let stdout = compile_and_run( + r#" +interface Context { + values: ReadonlyMap; +} + +class Holder { + constructor(public readonly ctx: Context) {} + lookup(key: number): string | undefined { + return this.ctx.values.get(key); + } +} + +const native = new Holder({ values: new Map([[2, "two"]]) }); +console.log("native", native.lookup(2), native.lookup(3)); + +let customCalls = 0; +const structural = { + get(key: number) { + customCalls++; + return key === 7 ? "seven" : undefined; + }, +} as unknown as ReadonlyMap; +const custom = new Holder({ values: structural }); +console.log("structural", custom.lookup(7), custom.lookup(8), customCalls); + +class OddMap extends Map { + override get(key: number): string | undefined { + return key === 99 ? "override" : undefined; + } +} +const subclass = new Holder({ values: new OddMap([[1, "one"]]) }); +console.log("subclass", subclass.lookup(99), subclass.lookup(1)); + +let nullishRejected = false; +try { + new Holder({ values: undefined as unknown as ReadonlyMap }).lookup(1); +} catch (_error) { + nullishRejected = true; +} +console.log("nullish", nullishRejected); +"#, + ); + + assert_eq!( + stdout, + "native two undefined\n\ + structural seven undefined 2\n\ + subclass override undefined\n\ + nullish true\n" + ); +} From 2cc2f73cfd88ea002df677ea036ec89473d45450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 18:38:47 +0200 Subject: [PATCH 13/15] chore: add changelog for #8830 --- ...ared-map-get-dispatch.md => 8830-declared-map-get-dispatch.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-declared-map-get-dispatch.md => 8830-declared-map-get-dispatch.md} (100%) diff --git a/changelog.d/0000-declared-map-get-dispatch.md b/changelog.d/8830-declared-map-get-dispatch.md similarity index 100% rename from changelog.d/0000-declared-map-get-dispatch.md rename to changelog.d/8830-declared-map-get-dispatch.md From e770386bd6b2e933bfd57ff21fc3f0a36094872b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 18:08:26 +0200 Subject: [PATCH 14/15] runtime(gc): resolve x19 frame-base roots in the fast fp-chain walk (#8770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLVM takes x19 as a frame base pointer for a function with a dynamic stack allocation (a VLA or a spread-argument area). Its GC roots are stack slots addressed via x19, and x19 is established as `mov x19, sp` immediately after the fixed prologue and before the dynamic `sub sp, sp, xN`, with no realignment — so x19 holds exactly the body SP the fp chain already reconstructs (`fp - fp_to_sp_offset`). Before this, any x19 root flipped the whole-image `chain_walkable` flag false (it required EVERY root to be fp/sp), which globally disabled the fast x29-chain root walk and forced every GC onto the platform unwinder. In cli.js just 63 of 72,812 functions use an x19 base, yet they disabled the correct fast walker for all of them; the unwinder then mis-resolved compiled-JS stack-slot roots and live young objects were swept (0xff-poison-receiver SIGSEGV / `(number).get is not a function`). Make `chain_walkable` accept x19 and resolve an x19 root like an SP root, gated per frame by `x19_is_body_sp` (confirms the `mov x19, sp` prologue shape); a frame that does not match still fails closed to the unwinder. Any other base register still disables the chain walk. Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF --- .../perry-runtime/src/gc/roots/stack_maps.rs | 125 ++++++++++++++++-- .../src/gc/roots/stack_maps_decode_tests.rs | 33 ++++- 2 files changed, 143 insertions(+), 15 deletions(-) diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index 0fb0bdd46d..fa9ad67a24 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -444,6 +444,18 @@ unsafe fn rewrite_derived_slot(derived_addr: usize, base_addr: usize, old_base_w // the target and this runtime's `target_arch` can never disagree. const DWARF_REG_FP_AARCH64: u16 = 29; const DWARF_REG_SP_AARCH64: u16 = 31; +// #8770: LLVM takes x19 as a frame base pointer for a function with a *dynamic* +// stack allocation (a VLA or a spread-argument area). The base is captured as +// `mov x19, sp` immediately after the fixed prologue and before the dynamic +// `sub sp, sp, xN`, with no realignment — so x19 holds exactly the body SP the +// fp chain reconstructs (`fp - fp_to_sp_offset`). The fast walker therefore +// resolves an x19-based root like an SP-based one, once `x19_is_body_sp` has +// confirmed that prologue shape for the owning function; a frame that does not +// match (e.g. a realigning one) fails closed to the platform unwinder. Before +// this these frames flipped the whole-image `chain_walkable` flag false and +// forced every walk onto the unwinder, whose root resolution the fast walker +// exists to avoid. +const DWARF_REG_X19_AARCH64: u16 = 19; // A frame record is two 64-bit words, so it needs EIGHT-byte alignment, not // sixteen. @@ -683,17 +695,22 @@ fn index_records( // dereferencing every function address at startup — unsafe for records // whose addresses are not live code, and unnecessary because the walker // already fails closed to the platform unwinder on any anomaly. - // The decoder only ever produces these two bases, but keep the check: it - // is what decides the fast walker is usable at all, and a format change - // that introduced a third base must disable the chain walk, not be - // trusted by it. + // The decoder produces three bases: FP, SP, and x19 (the base pointer LLVM + // uses for a dynamic-allocation frame — see `DWARF_REG_X19_AARCH64`). All + // three are chain-walkable: FP and SP directly, x19 because it is captured + // as `mov x19, sp` after the fixed prologue and so equals the body SP the + // walker already reconstructs. The x19 case is confirmed PER FRAME at walk + // time by `x19_is_body_sp`; a frame that fails that check fails closed to + // the unwinder without disabling the fast walk for the rest of the image. + // Any OTHER base (a format change, a register-located root) still disables + // the chain walk here rather than being trusted by it. let chain_walkable = roots .iter() .chain(derived.iter().map(|entry| &entry.slot)) .all(|location| { matches!( location.dwarf_reg, - DWARF_REG_FP_AARCH64 | DWARF_REG_SP_AARCH64 + DWARF_REG_FP_AARCH64 | DWARF_REG_SP_AARCH64 | DWARF_REG_X19_AARCH64 ) }); #[cfg(any(target_arch = "aarch64", test))] @@ -842,10 +859,13 @@ fn fp_to_sp_offset(function_address: usize) -> Option { // Anything else ends the prologue. Something later that // touches sp is a body operation (a dynamic alloca, a // call-argument area) which the stack map's own offsets - // already account for — and a frame that needs a base pointer - // for either reason records its roots against x19, which - // `chain_walkable` refuses for the whole image, so this walker - // never sees one. + // already account for. A frame that needs a base pointer for + // either reason records its roots against x19 — and x19 is + // captured as `mov x19, sp` right here, at the end of the fixed + // prologue, so it equals the body SP this function returns. + // `x19_is_body_sp` confirms that shape and the fast walker then + // resolves those roots off this same offset (#8770); it is no + // longer true that the walker never sees an x19 frame. break; } } @@ -857,6 +877,69 @@ fn fp_to_sp_offset(function_address: usize) -> Option { fp_offset } +/// True iff `function_address` establishes its x19 frame base as `mov x19, sp` +/// after only the fixed stack adjustments `fp_to_sp_offset` already folds in — +/// the shape (#8770) in which x19 equals the body SP the fp chain reconstructs, +/// so an x19-based root resolves exactly like an SP-based one at the same +/// offset. +/// +/// LLVM takes a base pointer (x19) for a frame with a *dynamic* stack +/// allocation and captures it right after the fixed prologue, before the +/// dynamic `sub sp, sp, xN`; that capture is `mov x19, sp` (`add x19, sp, #0`, +/// 0x9100_03F3). A *realigning* frame instead masks SP (`and sp, sp, #-align`) +/// before taking the base, and a base captured after a `sub sp, sp, xN` sits +/// below a dynamic adjustment — in both cases x19 is a runtime SP the chain +/// cannot reconstruct, so this returns false and the caller fails closed to the +/// platform unwinder, exactly as for any other frame the fast walk cannot +/// resolve. The accepted set between the frame-pointer setup and the base +/// capture is therefore precisely the one `fp_to_sp_offset` accumulates. +#[cfg(all( + any(target_vendor = "apple", target_os = "linux"), + target_arch = "aarch64" +))] +fn x19_is_body_sp(function_address: usize) -> bool { + // `add x19, sp, #0` — the base-pointer capture. `mov x19, sp` assembles to + // exactly this (Rn=sp=31, Rd=x19=19, imm=0). + const MOV_X19_SP: u32 = 0x9100_03F3; + const ADD_FP_SP_MASK: u32 = 0xFF80_03FF; + const ADD_FP_SP_PATTERN: u32 = 0x9100_03FD; + const SUB_SP_SP_MASK: u32 = 0xFF80_03FF; + const SUB_SP_SP_PATTERN: u32 = 0xD100_03FF; + const PROLOGUE_WINDOW_INSNS: usize = 24; + if function_address == 0 || function_address & 0x3 != 0 { + return false; + } + let mut fp_set = false; + for i in 0..PROLOGUE_WINDOW_INSNS { + let word = unsafe { std::ptr::read((function_address + i * 4) as *const u32) }; + if word == MOV_X19_SP { + // The base is captured from sp; it equals the reconstructed body SP + // only once the frame pointer — the walker's anchor — is set. + return fp_set; + } + if !fp_set { + fp_set = word & ADD_FP_SP_MASK == ADD_FP_SP_PATTERN; + // Everything before the frame pointer is set (the callee-save + // stores, the initial pre-index `stp`) leaves the fp<->sp + // relationship `fp_to_sp_offset` reconstructs intact, so it may + // precede the base capture. + continue; + } + // After the frame pointer is set, only the adjustments `fp_to_sp_offset` + // itself folds in may separate it from the base capture — anything else + // (a realigning `and sp`, a dynamic `sub sp, sp, xN`) means x19 is not + // the SP the walker reconstructs. + if word & SUB_SP_SP_MASK == SUB_SP_SP_PATTERN + || writes_sp_by_vector_length(word) + || is_frame_store_through_sp(word) + { + continue; + } + return false; + } + false +} + /// `stp`/`str` with SP as the base register and no writeback. /// /// These are the callee-save spills LLVM emits, and they do not modify sp — so @@ -1594,9 +1677,24 @@ mod fp_chain { // SP-relative record in the image (#7173). let sp = fp_to_sp_offset(record.function_address) .and_then(|off| caller_fp.checked_sub(off)); - // An SP-relative location with no decodable - // prologue used to abandon the walk from inside - // the location loop; keep that fail-closed + // #8770: an x19-based root resolves like an SP-based + // one (x19 == body SP) ONLY when the owning function + // captured its base as `mov x19, sp` after the fixed + // prologue. Confirm that per frame before trusting + // the `sp` base for its x19 slots; a frame that does + // not match fails closed to the unwinder like any + // other the fast walk cannot resolve. + let has_x19 = index + .locations(record) + .iter() + .chain(index.derived_locations(record).iter().map(|d| &d.slot)) + .any(|l| l.dwarf_reg == DWARF_REG_X19_AARCH64); + if has_x19 && !x19_is_body_sp(record.function_address) { + return None; + } + // An SP-relative (or x19-relative) location with no + // decodable prologue used to abandon the walk from + // inside the location loop; keep that fail-closed // answer, decided before any slot is visited. if sp.is_none() && index @@ -1608,6 +1706,9 @@ mod fp_chain { return None; } let mut resolve = |location: &StackMapLocation| { + // FP-based → the caller's x29; SP- and + // x19-based → the reconstructed body SP (x19 + // was proven equal to it above). let base = if location.dwarf_reg == DWARF_REG_FP_AARCH64 { caller_fp } else { diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs index d34013e969..c2b2eacb96 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs @@ -484,9 +484,12 @@ mod tests { } #[test] - fn an_explicit_base_register_disables_the_fast_walk() { - // The x29-chain walker can only recover FP and SP; anything else must - // fall back to the platform unwinder, which can. + fn an_x19_base_keeps_the_fast_walk_available() { + // #8770: x19 is the base pointer LLVM takes for a dynamic-allocation + // frame, captured as `mov x19, sp` after the fixed prologue — so it + // equals the body SP the x29-chain walker already reconstructs. It is + // chain-walkable (confirmed per frame at walk time by `x19_is_body_sp`), + // not a reason to force the whole image onto the platform unwinder. let index = index_records( vec![StackMapRecord { pc: 0x1000, @@ -503,6 +506,30 @@ mod tests { }], Vec::new(), ); + assert!(index.chain_walkable); + } + + #[test] + fn an_unsupported_base_register_disables_the_fast_walk() { + // The x29-chain walker recovers FP, SP, and x19 (== body SP); any OTHER + // base register it cannot derive from the frame, so such a record must + // fall back to the platform unwinder, which reads the frame's CFI. + let index = index_records( + vec![StackMapRecord { + pc: 0x1000, + function_address: 0x1000, + stack_size: 64, + roots_start: 0, + roots_len: 1, + derived_start: 0, + derived_len: 0, + }], + vec![StackMapLocation { + dwarf_reg: 5, + offset: -40, + }], + Vec::new(), + ); assert!(!index.chain_walkable); } From 7fa3a2f3ed557b8fad9289ef1d52e27130a1da8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 21:40:14 +0200 Subject: [PATCH 15/15] chore: runtime-validated verdict for is_declared_map_expr (#8830) --- scripts/local_binding_type_allowlist.json | 40 ++++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index c3ff53bcad..650d41211b 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -73,14 +73,6 @@ "classification": "representation-proven", "reason": "Array.isArray constant-folding uses only runtime-derived initializer evidence and rejects every binding written in the region." }, - { - "path": "crates/perry-codegen/src/expr/compare.rs", - "function": "is_proven_symbol_expr", - "access": "stable_local_type_proof", - "count": 1, - "classification": "representation-proven", - "reason": "Strict Symbol identity lowering accepts only constructor-derived runtime proof; the proof API rejects every binding written in the region, and fresh or registered Symbol storage is non-moving, so raw NaN-boxed pointer equality is the representation contract." - }, { "path": "crates/perry-codegen/src/expr/array_push.rs", "function": "guarded_numeric_add_push_candidate", @@ -97,6 +89,14 @@ "classification": "runtime-validated", "reason": "A real string operand fixes + as concatenation; the numeric hint selects a JSValue-taking concat helper that converts the current other operand at runtime." }, + { + "path": "crates/perry-codegen/src/expr/compare.rs", + "function": "is_proven_symbol_expr", + "access": "stable_local_type_proof", + "count": 1, + "classification": "representation-proven", + "reason": "Strict Symbol identity lowering accepts only constructor-derived runtime proof; the proof API rejects every binding written in the region, and fresh or registered Symbol storage is non-moving, so raw NaN-boxed pointer equality is the representation contract." + }, { "path": "crates/perry-codegen/src/expr/index_get.rs", "function": "is_width_tracked_typed_array_receiver", @@ -337,14 +337,6 @@ "classification": "runtime-validated", "reason": "The numeric annotation admits a candidate clone; the preheader checks the accumulator's current Number tag and the fact is scoped to a store-free, numeric-preserving fast clone." }, - { - "path": "crates/perry-codegen/src/stmt/if_stmt.rs", - "function": "try_const_fold_condition", - "access": "stable_local_type_proof", - "count": 1, - "classification": "runtime-validated", - "reason": "A falsy-local fold consumes only the private method clone's proof: its public wrapper bit-compares the live argument with TAG_UNDEFINED, candidate discovery rejects user writes and closure capture, and the proof API rejects every remaining reassigned binding." - }, { "path": "crates/perry-codegen/src/stmt/if_stmt.rs", "function": "lower_if", @@ -353,6 +345,14 @@ "classification": "metadata-only", "reason": "Restore bookkeeping for an if statement's branch-scoped narrowing: the snapshot is written back after each arm and is never read as a type fact." }, + { + "path": "crates/perry-codegen/src/stmt/if_stmt.rs", + "function": "try_const_fold_condition", + "access": "stable_local_type_proof", + "count": 1, + "classification": "runtime-validated", + "reason": "A falsy-local fold consumes only the private method clone's proof: its public wrapper bit-compares the live argument with TAG_UNDEFINED, candidate discovery rejects user writes and closure capture, and the proof API rejects every remaining reassigned binding." + }, { "path": "crates/perry-codegen/src/stmt/loops.rs", "function": "local_array_element_type", @@ -489,6 +489,14 @@ "classification": "representation-proven", "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_declared_map_expr", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "A declared Map/ReadonlyMap only nominates the branded route; it is never proof of Perry's native Map layout. The single consumer emits js_declared_map_get, which brand-checks the live receiver (try_read_gc_header + obj_type == GC_TYPE_MAP) before the fast path and otherwise roots both operands and falls back to ordinary js_native_call_method dispatch." + }, { "path": "crates/perry-codegen/src/type_analysis/strings.rs", "function": "is_declared_string_expr",