From 3cf4544803107feaf3450af42a0668bfeb0ba223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 11:23:43 +0200 Subject: [PATCH 01/13] fix(async_hooks): address lifecycle review feedback --- .../perry-codegen/src/expr/this_super_call.rs | 157 +++++++++++----- crates/perry-codegen/src/ext_registry.rs | 6 + .../perry-codegen/src/lower_call/builtin.rs | 20 ++- .../lower_call/native_table/http_client.rs | 2 +- .../lower_call/native_table/http_server.rs | 2 +- .../src/runtime_decls/stdlib_ffi/net_http.rs | 2 + crates/perry-ext-events/src/lib.rs | 95 +++++++--- .../perry-ext-events/src/module_iterators.rs | 61 ++++++- crates/perry-ext-events/src/module_on.rs | 34 ++-- crates/perry-ext-events/src/tests.rs | 20 ++- .../src/client_request_surface.rs | 24 +++ crates/perry-ext-http/src/lib.rs | 45 +++++ .../src/server/handle_dispatch.rs | 10 ++ crates/perry-ext-http/src/server/request.rs | 23 +++ crates/perry-ext-http/src/server/server.rs | 10 +- .../src/server/server/deferred_events.rs | 87 +++++---- crates/perry-ext-net/src/dispatch.rs | 39 ++-- crates/perry-ext-net/src/gc_roots.rs | 5 + crates/perry-ext-net/src/lib.rs | 92 ++++++---- crates/perry-ext-net/src/lifecycle.rs | 146 ++++++++++++--- .../perry-ext-net/src/provider_lifecycle.rs | 5 +- crates/perry-ext-net/src/raw_bridge.rs | 2 +- crates/perry-ext-zlib/src/stream.rs | 31 +++- crates/perry-runtime/src/async_context.rs | 8 + crates/perry-runtime/src/async_hooks.rs | 86 +++------ .../src/async_hooks/provider_ffi.rs | 168 ++++++++++++++++++ .../src/child_process/reactor.rs | 22 ++- crates/perry-runtime/src/dns.rs | 70 ++++++-- crates/perry-runtime/src/dns/ffi.rs | 8 +- .../src/fs/dir_glob_watch/watch.rs | 8 +- crates/perry-runtime/src/gc/mod.rs | 30 +++- crates/perry-runtime/src/module_require.rs | 13 +- .../src/node_stream_constructors/builders.rs | 4 +- .../src/node_stream_constructors/pipeline.rs | 5 - .../perry-runtime/src/node_stream_dispatch.rs | 140 ++++++++++----- .../src/node_submodules/fs_promises.rs | 17 +- crates/perry-runtime/src/object/instanceof.rs | 4 +- .../native_module_dispatch/dispatch_a_c.rs | 9 +- .../perry-runtime/src/promise/assimilate.rs | 18 +- .../perry-runtime/src/promise/async_step.rs | 8 + .../perry-runtime/src/promise/microtasks.rs | 17 +- crates/perry-runtime/src/promise/then.rs | 34 +++- crates/perry-runtime/src/proxy.rs | 36 ++-- crates/perry-runtime/src/timer.rs | 13 +- .../perry-stdlib/src/async_local_storage.rs | 74 +++++--- .../src/common/dispatch/emitter_als.rs | 22 ++- .../perry-stdlib/src/common/dispatch_http.rs | 15 +- crates/perry-stdlib/src/tls/event_pump.rs | 115 ++++++++---- crates/perry-stdlib/src/webcrypto/digest.rs | 32 +++- crates/perry-stdlib/src/webcrypto/hmac.rs | 12 +- crates/perry-stdlib/src/webcrypto/util.rs | 5 - .../src/worker_threads/worker_pump.rs | 14 +- crates/perry-stdlib/src/zlib.rs | 10 ++ .../perry/src/commands/compile/build_cache.rs | 2 - scripts/raw_handle_debt_baseline.txt | 2 +- scripts/thread_local_cold_allowlist.json | 2 +- 56 files changed, 1445 insertions(+), 496 deletions(-) create mode 100644 crates/perry-runtime/src/async_hooks/provider_ffi.rs diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 42f62ef709..ad8b47a3a8 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -9,6 +9,7 @@ use perry_hir::Expr; use crate::lower_call::{bind_inline_constructor_params, restore_inline_constructor_scope}; use crate::nanbox::{double_literal, POINTER_MASK_I64}; +use crate::rooting::{self, Repr}; use crate::types::{DOUBLE, I1, I32, I64, PTR}; use super::{ @@ -257,6 +258,67 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Some(slot) => ctx.block().load(DOUBLE, &slot), None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; + let async_parent = ctx + .classes + .get(¤t_class_name) + .and_then(|class| class.extends_name.clone()); + if matches!( + async_parent.as_deref(), + Some("EventEmitterAsyncResource" | "AsyncLocalStorage" | "AsyncResource") + ) { + 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| { + let this_root = group.adopt_emitted(ctx, Repr::Boxed, &this_box, true); + let first_root = group.adopt_emitted(ctx, Repr::Boxed, &first, true); + 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() { + Some("EventEmitterAsyncResource") => { + let options = group.reread_emitted(ctx, first_root); + lower_event_emitter_async_resource_subclass_init( + ctx, &this_box, &options, + ); + } + Some("AsyncLocalStorage") => { + ctx.block().call( + DOUBLE, + "js_async_local_storage_subclass_init", + &[(DOUBLE, &this_box)], + ); + } + Some("AsyncResource") => { + let type_value = group.reread_emitted(ctx, first_root); + let options = group.reread_emitted(ctx, second_root); + ctx.block().call( + DOUBLE, + "js_async_resource_subclass_init", + &[ + (DOUBLE, &this_box), + (DOUBLE, &type_value), + (DOUBLE, &options), + ], + ); + } + _ => unreachable!(), + } + bind_derived_this_after_super(ctx); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + Ok(undef.clone()) + })?; + return Ok(undef); + } // `class X extends Map | Set` with a spread super (`super(...args)`, // e.g. NestJS's `ModulesContainer`'s `super(...arguments)`) — install // the hidden collection backing from the (possibly spread) args @@ -828,27 +890,28 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } if parent_name.as_str() == "EventEmitterAsyncResource" { - let mut lowered = Vec::with_capacity(super_args.len()); - for arg in super_args { - lowered.push(lower_expr(ctx, arg)?); - } - let options = lowered.first().cloned().unwrap_or_else(|| { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let operands: Vec<_> = super_args.iter().collect(); + return rooting::with_operands_rooted(ctx, &operands, |ctx, lowered| { + let options = lowered.first().cloned().unwrap_or_else(|| { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }); + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + } + }; + lower_event_emitter_async_resource_subclass_init( + ctx, &this_box, &options, + ); + bind_derived_this_after_super(ctx); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) }); - let this_box = match ctx.this_stack.last().cloned() { - Some(slot) => ctx.block().load(DOUBLE, &slot), - None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), - }; - lower_event_emitter_async_resource_subclass_init(ctx, &this_box, &options); - bind_derived_this_after_super(ctx); - let current_class_name = - ctx.class_stack.last().cloned().unwrap_or_default(); - crate::lower_call::apply_field_initializers_recursive( - ctx, - ¤t_class_name, - crate::lower_call::FieldInitMode::SelfOnly, - )?; - return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } if parent_name.as_str() == "AsyncLocalStorage" { for arg in super_args { @@ -875,34 +938,32 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } if parent_name.as_str() == "AsyncResource" { let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - let mut lowered = Vec::with_capacity(super_args.len()); - for arg in super_args { - lowered.push(lower_expr(ctx, arg)?); - } - let type_value = lowered.first().cloned().unwrap_or_else(|| undef.clone()); - let options = lowered.get(1).cloned().unwrap_or_else(|| undef.clone()); - let this_box = match ctx.this_stack.last().cloned() { - Some(slot) => ctx.block().load(DOUBLE, &slot), - None => undef, - }; - ctx.block().call( - DOUBLE, - "js_async_resource_subclass_init", - &[ - (DOUBLE, &this_box), - (DOUBLE, &type_value), - (DOUBLE, &options), - ], - ); - bind_derived_this_after_super(ctx); - let current_class_name = - ctx.class_stack.last().cloned().unwrap_or_default(); - crate::lower_call::apply_field_initializers_recursive( - ctx, - ¤t_class_name, - crate::lower_call::FieldInitMode::SelfOnly, - )?; - return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + let operands: Vec<_> = super_args.iter().collect(); + return rooting::with_operands_rooted(ctx, &operands, |ctx, lowered| { + let type_value = + lowered.first().cloned().unwrap_or_else(|| undef.clone()); + let options = lowered.get(1).cloned().unwrap_or_else(|| undef.clone()); + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => undef.clone(), + }; + ctx.block().call( + DOUBLE, + "js_async_resource_subclass_init", + &[ + (DOUBLE, &this_box), + (DOUBLE, &type_value), + (DOUBLE, &options), + ], + ); + bind_derived_this_after_super(ctx); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) + }); } // `class X extends Request` / `extends Response`: // `super(input, init)` allocates the underlying native diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index cd1a27c466..f8994e1185 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -220,6 +220,7 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_https_request", OwnerKind::WellKnown("http")), ("js_https_get", OwnerKind::WellKnown("http")), ("js_http_on", OwnerKind::WellKnown("http")), + ("js_http_once", OwnerKind::WellKnown("http")), ("js_http_set_header", OwnerKind::WellKnown("http")), ("js_http_set_timeout", OwnerKind::WellKnown("http")), ("js_http_set_timeout_full", OwnerKind::WellKnown("http")), @@ -300,6 +301,7 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_node_http_server_ref", OwnerKind::WellKnown("http")), ("js_node_http_server_unref", OwnerKind::WellKnown("http")), ("js_node_http_im_on", OwnerKind::WellKnown("http")), + ("js_node_http_im_once", OwnerKind::WellKnown("http")), ("js_node_http_im_pause", OwnerKind::WellKnown("http")), ("js_node_http_im_resume", OwnerKind::WellKnown("http")), ("js_node_http_im_pause_self", OwnerKind::WellKnown("http")), @@ -549,6 +551,8 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_event_emitter_new", OwnerKind::WellKnown("events")), ("js_event_emitter_new_with_options", OwnerKind::WellKnown("events")), ("js_event_emitter_async_resource_new", OwnerKind::WellKnown("events")), + ("js_event_emitter_async_resource_call", OwnerKind::WellKnown("events")), + ("js_event_emitter_async_resource_subclass_init", OwnerKind::WellKnown("events")), ("js_event_emitter_async_resource_async_id", OwnerKind::WellKnown("events")), ("js_event_emitter_async_resource_trigger_async_id", OwnerKind::WellKnown("events")), ("js_event_emitter_async_resource_async_resource", OwnerKind::WellKnown("events")), @@ -1126,6 +1130,8 @@ mod tests { "js_event_emitter_set_max_listeners", "js_event_emitter_get_max_listeners", "js_event_emitter_domain_value", + "js_event_emitter_async_resource_call", + "js_event_emitter_async_resource_subclass_init", ] { assert_symbol_routes_to(symbol, OwnerKind::WellKnown("events")); } diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 637ec3b3a2..6e3e29f1ba 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -145,9 +145,14 @@ pub(super) fn lower_builtin_new<'a>( // the native-module call table used by `dns.Resolver()`. Route it // to the same runtime constructor and preserve evaluation of any // superfluous arguments. - for arg in args { + let options_idx = adopt_optional_arg(ctx, args, 0, group)?; + for arg in args.iter().skip(1) { let _ = lower_expr(ctx, arg)?; } + let options = match options_idx { + Some(index) => group.reread(ctx, index)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let runtime = if import_src.is_some_and(|source| { source.strip_prefix("node:").unwrap_or(source) == "dns/promises" }) { @@ -157,7 +162,18 @@ pub(super) fn lower_builtin_new<'a>( }; ctx.pending_declares .push((runtime.to_string(), DOUBLE, vec![I64])); - Ok(Some(ctx.block().call(DOUBLE, runtime, &[(I64, "0")]))) + 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)], + ); + Ok(Some(ctx.block().call( + DOUBLE, + runtime, + &[(I64, &args_array)], + ))) } "Utf8Stream" if import_src diff --git a/crates/perry-codegen/src/lower_call/native_table/http_client.rs b/crates/perry-codegen/src/lower_call/native_table/http_client.rs index 2da650f54b..eb43c085f1 100644 --- a/crates/perry-codegen/src/lower_call/native_table/http_client.rs +++ b/crates/perry-codegen/src/lower_call/native_table/http_client.rs @@ -141,7 +141,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "once", class_filter: Some("ClientRequest"), - runtime: "js_http_on", + runtime: "js_http_once", args: &[NA_STR, NA_PTR], ret: NR_PTR, }, diff --git a/crates/perry-codegen/src/lower_call/native_table/http_server.rs b/crates/perry-codegen/src/lower_call/native_table/http_server.rs index 38f960482d..dc044db30a 100644 --- a/crates/perry-codegen/src/lower_call/native_table/http_server.rs +++ b/crates/perry-codegen/src/lower_call/native_table/http_server.rs @@ -391,7 +391,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "once", class_filter: Some("IncomingMessage"), - runtime: "js_node_http_im_on", + runtime: "js_node_http_im_once", args: &[NA_STR, NA_PTR], ret: NR_F64, }, diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs index 1b0f9ec8ce..4628dbdfec 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs @@ -112,6 +112,7 @@ pub(crate) fn declare_net_http(module: &mut LlModule) { module.declare_function("js_https_get_overload", I64, &[I64]); module.declare_function("js_https_request_overload", I64, &[I64]); module.declare_function("js_http_on", I64, &[I64, I64, I64]); + module.declare_function("js_http_once", I64, &[I64, I64, I64]); module.declare_function("js_http_request", I64, &[DOUBLE, I64]); module.declare_function("js_http_request_body", I64, &[I64]); module.declare_function("js_http_request_body_length", DOUBLE, &[I64]); @@ -222,6 +223,7 @@ pub(crate) fn declare_net_http(module: &mut LlModule) { module.declare_function("js_node_http_im_resume", VOID, &[I64]); module.declare_function("js_node_http_im_destroy", VOID, &[I64]); module.declare_function("js_node_http_im_on", DOUBLE, &[I64, I64, I64]); + module.declare_function("js_node_http_im_once", DOUBLE, &[I64, I64, I64]); module.declare_function("js_node_http_im_read", DOUBLE, &[I64]); module.declare_function("js_node_http_im_set_timeout", I64, &[I64, DOUBLE, I64]); // ServerResponse: diff --git a/crates/perry-ext-events/src/lib.rs b/crates/perry-ext-events/src/lib.rs index 3863c15c99..cfeb124c79 100644 --- a/crates/perry-ext-events/src/lib.rs +++ b/crates/perry-ext-events/src/lib.rs @@ -62,6 +62,7 @@ use module_iterators::{ events_on_queue_listener, events_on_state_new, events_on_state_set_target, events_once_abort_listener, events_once_event_target_listener, events_once_stream_reject_listener, events_once_stream_resolve_listener, + EVENTS_ON_EVENT_EMITTER, EVENTS_ON_EVENT_TARGET, EVENTS_ON_NET_HANDLE, EVENTS_ON_STREAM, }; const MIN_HEAP_POINTER: u64 = 0x1000; @@ -201,8 +202,11 @@ extern "C" { fn js_async_resource_emit_destroy(handle: i64) -> i64; fn js_async_resource_set_event_emitter(handle: i64, event_emitter: i64); fn js_event_emitter_async_resource_subclass_backing(receiver: i64) -> i64; - 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 std::ffi::c_void) -> f64, + data: *mut std::ffi::c_void, + ) -> f64; } /// #3072: validate an EventEmitter listener argument, returning the closure @@ -1291,16 +1295,47 @@ pub unsafe extern "C" fn js_event_emitter_emit( handle: Handle, event_bits: i64, args_ptr: *mut ArrayHeader, +) -> f64 { + if event_name_from_bits(event_bits).is_none() { + 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); + } + let mut call = EventEmitterEmitCall { + handle, + event_bits, + args_ptr, + }; + js_async_hooks_provider_run_catching( + async_id, + event_emitter_emit_thunk, + &mut call as *mut EventEmitterEmitCall as *mut std::ffi::c_void, + ) +} + +struct EventEmitterEmitCall { + handle: Handle, + event_bits: i64, + args_ptr: *mut ArrayHeader, +} + +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) +} + +unsafe fn js_event_emitter_emit_impl( + handle: Handle, + event_bits: i64, + 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 async_id = event_emitter_async_id(handle); - if async_id != 0 { - js_async_hooks_provider_enter(async_id); - } let mut had_listeners = false; let mut domain_error: Option<(Handle, f64)> = None; let mut throw_error: Option = None; @@ -1354,23 +1389,16 @@ pub unsafe extern "C" fn js_event_emitter_emit( } if let Some((domain, error)) = domain_error { let _ = js_domain_emit_error(domain, error, nanbox_pointer_bits(handle), false); - if async_id != 0 { - js_async_hooks_provider_leave(async_id); - } return TAG_FALSE_F64; } if let Some(error) = throw_error { js_throw(error); } - let result = if had_listeners { + if had_listeners { TAG_TRUE_F64 } else { TAG_FALSE_F64 - }; - if async_id != 0 { - js_async_hooks_provider_leave(async_id); } - result } /// `emitter.emit(eventName)` — no-args variant. @@ -1384,15 +1412,37 @@ pub unsafe extern "C" fn js_event_emitter_emit( /// `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() { + 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); + } + let mut call = EventEmitterEmit0Call { handle, event_bits }; + js_async_hooks_provider_run_catching( + async_id, + event_emitter_emit0_thunk, + &mut call as *mut EventEmitterEmit0Call as *mut std::ffi::c_void, + ) +} + +struct EventEmitterEmit0Call { + handle: Handle, + event_bits: i64, +} + +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) +} + +unsafe fn js_event_emitter_emit0_impl(handle: Handle, event_bits: i64) -> 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 async_id = event_emitter_async_id(handle); - if async_id != 0 { - js_async_hooks_provider_enter(async_id); - } let mut had_listeners = false; let mut domain_error: Option<(Handle, f64)> = None; let mut throw_error: Option = None; @@ -1445,23 +1495,16 @@ pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) } if let Some((domain, error)) = domain_error { let _ = js_domain_emit_error(domain, error, nanbox_pointer_bits(handle), false); - if async_id != 0 { - js_async_hooks_provider_leave(async_id); - } return TAG_FALSE_F64; } if let Some(error) = throw_error { js_throw(error); } - let result = if had_listeners { + if had_listeners { TAG_TRUE_F64 } else { TAG_FALSE_F64 - }; - if async_id != 0 { - js_async_hooks_provider_leave(async_id); } - result } /// `emitter.removeListener(event, listener)`. Removes the most recently added diff --git a/crates/perry-ext-events/src/module_iterators.rs b/crates/perry-ext-events/src/module_iterators.rs index 24e38bb39d..e7584299df 100644 --- a/crates/perry-ext-events/src/module_iterators.rs +++ b/crates/perry-ext-events/src/module_iterators.rs @@ -141,11 +141,17 @@ const EVENTS_ON_DONE: u32 = 2; const EVENTS_ON_ABORT: u32 = 3; const EVENTS_ON_HANDLE: u32 = 4; const EVENTS_ON_LISTENER: u32 = 5; +const EVENTS_ON_EVENT_NAME: u32 = 6; +const EVENTS_ON_TARGET_KIND: u32 = 7; +pub(super) const EVENTS_ON_EVENT_EMITTER: u32 = 0; +pub(super) const EVENTS_ON_EVENT_TARGET: u32 = 1; +pub(super) const EVENTS_ON_NET_HANDLE: u32 = 2; +pub(super) const EVENTS_ON_STREAM: u32 = 3; const EVENTS_ON_ITER_SHAPE_ID: u32 = 0x7FFF_FF60; pub(super) unsafe fn events_on_state_new() -> *mut ArrayHeader { let scope = TransientRootScope::enter(); - let state = js_array_alloc(6); + let state = js_array_alloc(8); let state_root = scope.root_nanbox(nanbox_pointer_bits(state as i64)); let buffer = js_array_alloc(0); let buffer_root = scope.root_nanbox(nanbox_pointer_bits(buffer as i64)); @@ -158,6 +164,8 @@ pub(super) unsafe fn events_on_state_new() -> *mut ArrayHeader { let _ = js_array_push_f64(state_ptr(), undefined_value()); let _ = js_array_push_f64(state_ptr(), undefined_value()); let _ = js_array_push_f64(state_ptr(), undefined_value()); + let _ = js_array_push_f64(state_ptr(), undefined_value()); + let _ = js_array_push_f64(state_ptr(), undefined_value()); state_ptr() } @@ -172,15 +180,19 @@ unsafe fn events_on_state_set(state: *mut ArrayHeader, index: u32, value: f64) { pub(super) unsafe fn events_on_state_set_target( state: *mut ArrayHeader, - handle: Handle, + target: f64, listener: *mut RawClosureHeader, + event_name: f64, + target_kind: u32, ) { - events_on_state_set(state, EVENTS_ON_HANDLE, handle as f64); + events_on_state_set(state, EVENTS_ON_HANDLE, target); events_on_state_set( state, EVENTS_ON_LISTENER, nanbox_pointer_bits(listener as i64), ); + events_on_state_set(state, EVENTS_ON_EVENT_NAME, event_name); + events_on_state_set(state, EVENTS_ON_TARGET_KIND, target_kind as f64); } fn events_on_iter_result(value: f64, done: bool) -> f64 { @@ -246,6 +258,12 @@ pub(super) extern "C" fn events_on_queue_listener( if !state.is_null() { let scope = TransientRootScope::enter(); let state_root = scope.root_nanbox(nanbox_pointer_bits(state as i64)); + let current_state = (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader; + if f64::from_bits(js_array_get(current_state, EVENTS_ON_DONE).bits()).to_bits() + == TAG_TRUE_F64_BITS + { + return f64::from_bits(TAG_UNDEFINED_F64_BITS); + } let mut args = js_array_alloc(0); args = js_array_push_f64(args, arg0); let args_root = scope.root_nanbox(nanbox_pointer_bits(args as i64)); @@ -307,11 +325,40 @@ extern "C" fn events_on_return(closure: *const RawClosureHeader) -> f64 { return events_on_resolved(undefined_value(), true); } events_on_state_set(state, EVENTS_ON_DONE, f64::from_bits(TAG_TRUE_F64_BITS)); - let handle = f64::from_bits(js_array_get(state, EVENTS_ON_HANDLE).bits()); + let target = f64::from_bits(js_array_get(state, EVENTS_ON_HANDLE).bits()); let listener = f64::from_bits(js_array_get(state, EVENTS_ON_LISTENER).bits()); - if handle.is_finite() && listener.to_bits() != TAG_UNDEFINED_F64_BITS { - if let Some(emitter) = get_event_emitter_mut(handle as Handle) { - remove_listener_by_callback(emitter, (listener.to_bits() & POINTER_MASK) as i64); + let event_name = f64::from_bits(js_array_get(state, EVENTS_ON_EVENT_NAME).bits()); + let target_kind = f64::from_bits(js_array_get(state, EVENTS_ON_TARGET_KIND).bits()) as u32; + if listener.to_bits() != TAG_UNDEFINED_F64_BITS { + let listener_ptr = (listener.to_bits() & POINTER_MASK) as i64; + match target_kind { + EVENTS_ON_EVENT_EMITTER => { + if let Some(emitter) = get_event_emitter_mut(target as Handle) { + remove_listener_by_callback(emitter, listener_ptr); + } + } + EVENTS_ON_EVENT_TARGET => { + let target_ptr = (target.to_bits() & POINTER_MASK) as *mut u8; + let event_ptr = (event_name.to_bits() & POINTER_MASK) as *const StringHeader; + if !target_ptr.is_null() && !event_ptr.is_null() { + js_event_target_remove_event_listener(target_ptr, event_ptr, listener_ptr); + } + } + EVENTS_ON_NET_HANDLE => { + let _ = call_net_socket_method( + target as Handle, + "removeListener", + &[event_name, listener], + ); + } + EVENTS_ON_STREAM => { + let _ = js_node_stream_method_remove_listener( + target as Handle, + event_name, + listener, + ); + } + _ => {} } } events_on_finish_pending(state, None); diff --git a/crates/perry-ext-events/src/module_on.rs b/crates/perry-ext-events/src/module_on.rs index c6b7ff789e..a583696c80 100644 --- a/crates/perry-ext-events/src/module_on.rs +++ b/crates/perry-ext-events/src/module_on.rs @@ -8,8 +8,12 @@ pub unsafe extern "C" fn js_events_on( ) -> *mut ArrayHeader { ensure_gc_scanner_registered(); let root_scope = TransientRootScope::enter(); - let target = - event_helper_target(target_value).unwrap_or_else(|| throw_invalid_emitter(target_value)); + let target_root = root_scope.root_nanbox(target_value); + let event_name_root = root_scope.root_nanbox(f64::from_bits(nanbox_string_bits( + string_header_ptr_from_arg(event_name_ptr) as *mut StringHeader, + ))); + let _ = event_helper_target(target_root.get()) + .unwrap_or_else(|| throw_invalid_emitter(target_root.get())); let queue = js_array_alloc(0); let queue_root = root_scope.root_nanbox(nanbox_pointer_bits(queue as i64)); let state = events_on_state_new(); @@ -18,10 +22,10 @@ pub unsafe extern "C" fn js_events_on( (queue_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader, (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader, ); - let Some(event_name) = event_name_from_bits(event_name_ptr as i64) else { + let Some(event_name) = event_name_from_bits(event_name_root.get().to_bits() as i64) else { return (queue_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader; }; - let event_name_ptr = string_header_ptr_from_arg(event_name_ptr); + let event_name_ptr = (event_name_root.get().to_bits() & POINTER_MASK) as *const StringHeader; let signal = options_signal_or_throw(options); if signal.is_some_and(signal_is_aborted) { js_throw(js_abort_error_value()); @@ -33,26 +37,32 @@ pub unsafe extern "C" fn js_events_on( (state_root.get().to_bits() & POINTER_MASK) as i64, ); let listener_root = root_scope.root_addr(listener as i64); - let handle = match target { + let target = event_helper_target(target_root.get()) + .unwrap_or_else(|| throw_invalid_emitter(target_root.get())); + let (handle, cleanup_target, cleanup_kind) = match target { EventHelperTarget::EventEmitter(handle) => { if let Some(emitter) = get_event_emitter_mut(handle) { emitter.add_listener(handle, &event_name, listener_root.get(), false, false); } - handle + (handle, handle as f64, EVENTS_ON_EVENT_EMITTER) } EventHelperTarget::EventTarget(target) => { if !event_name_ptr.is_null() { js_event_target_add_event_listener(target, event_name_ptr, listener_root.get()); } - target as Handle + ( + target as Handle, + nanbox_pointer_bits(target as i64), + EVENTS_ON_EVENT_TARGET, + ) } EventHelperTarget::NetSocket(handle) | EventHelperTarget::NativeHandle(handle) => { if !event_name_ptr.is_null() { let event = f64::from_bits(nanbox_string_bits(event_name_ptr as *mut StringHeader)); - let listener_value = nanbox_pointer_bits(listener as i64); + let listener_value = nanbox_pointer_bits(listener_root.get()); let _ = call_net_socket_method(handle, "on", &[event, listener_value]); } - handle + (handle, handle as f64, EVENTS_ON_NET_HANDLE) } EventHelperTarget::Stream(handle) => { if !event_name_ptr.is_null() { @@ -60,13 +70,15 @@ pub unsafe extern "C" fn js_events_on( let listener_value = nanbox_pointer_bits(listener_root.get()); let _ = js_node_stream_method_on(handle, event, listener_value); } - handle + (handle, handle as f64, EVENTS_ON_STREAM) } }; events_on_state_set_target( (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader, - handle, + cleanup_target, listener_root.get() as *mut RawClosureHeader, + event_name_root.get(), + cleanup_kind, ); if let Some(close) = get_object_property(options, b"close") { if js_array_is_array(close).to_bits() == TAG_TRUE_F64_BITS { diff --git a/crates/perry-ext-events/src/tests.rs b/crates/perry-ext-events/src/tests.rs index 44f1767e9a..d65797ac45 100644 --- a/crates/perry-ext-events/src/tests.rs +++ b/crates/perry-ext-events/src/tests.rs @@ -7,6 +7,7 @@ static GC_TEST_LOCK: Mutex<()> = Mutex::new(()); struct GcTestGuard { frame: u64, + previous_force_evacuation: i32, _lock: MutexGuard<'static, ()>, } @@ -16,15 +17,17 @@ impl GcTestGuard { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); // This test asserts that mutable roots are rewritten, which is only - // observable when the collector moves its survivors. Force evacuation - // for the serialized GC-test window; the policy may otherwise choose - // a valid non-moving collection under unit-test pressure. - // - // SAFETY: `GC_TEST_LOCK` is held for the guard's whole lifetime. - unsafe { std::env::set_var("PERRY_GC_FORCE_EVACUATE", "1") }; + // observable when the collector moves its survivors. Use the + // runtime's thread-local override so unrelated test threads never + // observe a process-wide environment mutation. + let previous_force_evacuation = perry_runtime::gc::js_gc_force_evacuation_test_override(1); perry_runtime::gc::js_gc_write_barriers_emitted(1); let frame = perry_runtime::gc::js_shadow_frame_push(0); - Self { frame, _lock: lock } + Self { + frame, + previous_force_evacuation, + _lock: lock, + } } } @@ -32,8 +35,7 @@ impl Drop for GcTestGuard { fn drop(&mut self) { perry_runtime::gc::js_shadow_frame_pop(self.frame); perry_runtime::gc::js_gc_write_barriers_emitted(0); - // SAFETY: still under `GC_TEST_LOCK` (dropped after this body). - unsafe { std::env::remove_var("PERRY_GC_FORCE_EVACUATE") }; + perry_runtime::gc::js_gc_force_evacuation_test_override(self.previous_force_evacuation); } } diff --git a/crates/perry-ext-http/src/client_request_surface.rs b/crates/perry-ext-http/src/client_request_surface.rs index db87e79fbf..faad8fe08e 100644 --- a/crates/perry-ext-http/src/client_request_surface.rs +++ b/crates/perry-ext-http/src/client_request_surface.rs @@ -108,6 +108,30 @@ extern "C" fn client_once_wrapper(closure: *const RawClosureHeader, rest: f64) - listeners.remove(position); true }) + .or_else(|| { + with_handle_mut::(handle, |response| { + let Some(listeners) = response.listeners.get_mut(&event) else { + return false; + }; + let Some(position) = listeners.iter().rposition(|entry| *entry == wrapper) else { + return false; + }; + listeners.remove(position); + true + }) + }) + .or_else(|| { + with_handle_mut::(handle, |request| { + let Some(listeners) = request.listeners.get_mut(&event) else { + return false; + }; + let Some(position) = listeners.iter().rposition(|entry| *entry == wrapper) else { + return false; + }; + listeners.remove(position); + true + }) + }) .unwrap_or(false); if !removed || callback == 0 { return undefined_value(); diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index c020357abb..1e357215ab 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -756,6 +756,13 @@ extern "C" { fn js_async_hooks_provider_enter(async_id: u64); fn js_async_hooks_provider_leave(async_id: u64); fn js_async_hooks_provider_destroy(async_id: u64); + fn js_async_hooks_provider_run_catching_with_this( + async_id: u64, + this_value: f64, + destroy_after: i32, + callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64, + data: *mut std::ffi::c_void, + ) -> f64; } fn pending_request_handle(event: &PendingHttpEvent) -> Handle { @@ -1780,6 +1787,44 @@ pub unsafe extern "C" fn js_http_on( http_on_impl(handle, event_ptr, callback) } +/// `req.once(event, cb)` / client `res.once(event, cb)` — register a wrapper +/// that removes itself before invoking the original callback. +#[no_mangle] +pub unsafe extern "C" fn js_http_once( + handle: Handle, + event_ptr: *const StringHeader, + callback: i64, +) -> Handle { + ensure_gc_scanner_registered(); + let Some(event) = read_str(event_ptr) else { + return handle; + }; + if callback == 0 { + return handle; + } + let wrapper = + client_request_surface::create_client_once_wrapper(handle, &event, callback, false); + let mut matched = false; + with_handle_mut::(handle, |request| { + request + .listeners + .entry(event.clone()) + .or_default() + .push(ClientEventListener { + callback, + raw_wrapper: wrapper, + once: true, + }); + matched = true; + }); + if !matched { + with_handle_mut::(handle, |response| { + response.listeners.entry(event).or_default().push(wrapper); + }); + } + handle +} + unsafe fn http_on_impl(handle: Handle, event_ptr: *const StringHeader, callback: i64) -> Handle { ensure_gc_scanner_registered(); let event = match read_str(event_ptr) { diff --git a/crates/perry-ext-http/src/server/handle_dispatch.rs b/crates/perry-ext-http/src/server/handle_dispatch.rs index 398677b020..86ad3ea08b 100644 --- a/crates/perry-ext-http/src/server/handle_dispatch.rs +++ b/crates/perry-ext-http/src/server/handle_dispatch.rs @@ -126,6 +126,8 @@ 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; @@ -372,6 +374,14 @@ 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-http/src/server/request.rs b/crates/perry-ext-http/src/server/request.rs index fbb6e8af5a..7d675a11bc 100644 --- a/crates/perry-ext-http/src/server/request.rs +++ b/crates/perry-ext-http/src/server/request.rs @@ -704,6 +704,29 @@ pub unsafe extern "C" fn js_node_http_im_on( f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)) } +/// `IncomingMessage#once` for both server requests and client responses. +#[no_mangle] +pub unsafe extern "C" fn js_node_http_im_once( + handle: i64, + event_name_ptr: *const StringHeader, + callback: i64, +) -> f64 { + if get_handle_mut::(handle).is_none() { + extern "C" { + fn js_http_once(handle: i64, event_ptr: *const StringHeader, callback: i64) -> i64; + } + let _ = js_http_once(handle, event_name_ptr, callback); + return f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)); + } + let event = read_string_header(event_name_ptr as *mut _).unwrap_or_default(); + if event.is_empty() || callback == 0 { + return f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)); + } + let wrapper = + crate::client_request_surface::create_client_once_wrapper(handle, &event, callback, false); + js_node_http_im_on(handle, event_name_ptr, wrapper) +} + /// `req.setEncoding(encoding)` — switch future `'data'` events from Buffer /// chunks to decoded string chunks. Returns the receiver for chaining. #[no_mangle] diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index 16af91d477..6cb0a0212b 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -776,9 +776,6 @@ pub unsafe extern "C" fn js_node_http_server_listen(server_handle: i64, args_arr .host .unwrap_or_else(|| extract_host(opts_f64, "0.0.0.0")); let callback = parsed.callback; - let server_async_id = - crate::js_async_hooks_provider_init(b"TCPSERVERWRAP".as_ptr(), b"TCPSERVERWRAP".len()); - let (request_tx, request_rx) = mpsc::channel::(1024); let (upgrade_tx, upgrade_rx) = mpsc::channel::(256); let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); @@ -941,9 +938,16 @@ pub unsafe extern "C" fn js_node_http_server_listen(server_handle: i64, args_arr // `server`, so `server.address()` inside the callback threw // "Cannot read properties of undefined". The pump fires both with // `this` bound to the server (#2132), via `drain_deferred_listen_events`. + // Initialize the provider only after every synchronous setup step has + // succeeded. Failure returns above therefore cannot leak a resource with + // no matching destroy edge. + let server_async_id = + crate::js_async_hooks_provider_init(b"TCPSERVERWRAP".as_ptr(), b"TCPSERVERWRAP".len()); if let Some(s) = get_handle_mut::(server_handle) { s.async_id = server_async_id; queue_deferred_listening_emit(s, callback); + } else { + crate::js_async_hooks_provider_destroy(server_async_id); } // Closes #604 — `listen()` is now non-blocking. The accept loop is diff --git a/crates/perry-ext-http/src/server/server/deferred_events.rs b/crates/perry-ext-http/src/server/server/deferred_events.rs index 075b57b59d..0d1cba9b24 100644 --- a/crates/perry-ext-http/src/server/server/deferred_events.rs +++ b/crates/perry-ext-http/src/server/server/deferred_events.rs @@ -2,6 +2,29 @@ use super::*; +struct DeferredCallbacksCall { + callbacks: *const perry_ffi::TransientRootedAddr, + len: usize, +} + +unsafe extern "C" fn call_deferred_callbacks(data: *mut std::ffi::c_void) -> f64 { + let call = &*(data as *const DeferredCallbacksCall); + let callbacks = std::slice::from_raw_parts(call.callbacks, call.len); + let mut fired = 0; + for callback in callbacks { + let callback = callback.get(); + if callback == 0 { + continue; + } + let closure = JsClosure::from_raw(callback as *const RawClosureHeader); + if !closure.is_null() { + let _ = closure.call0(); + fired += 1; + } + } + fired as f64 +} + /// #4903 — record a pending `'listening'` emit on a server (http / https / /// http2 all share the `HttpServer` base). Node registers the /// `listen(port, cb)` callback as a *once* `'listening'` listener inside @@ -68,32 +91,23 @@ where } None => return 0, }; - if async_id != 0 { - unsafe { crate::js_async_hooks_provider_enter(async_id) }; - } let this_val = handle_to_pointer_f64(server_handle); - let mut fired = 0i32; // #8082: the drained snapshot crosses each callback — root it. let scope = perry_ffi::TransientRootScope::enter(); let rooted = scope.root_addrs(&cbs); - for cb in &rooted { - let addr = cb.get(); - if addr == 0 { - continue; - } - let raw = addr as *const RawClosureHeader; - let closure = unsafe { JsClosure::from_raw(raw) }; - if !closure.is_null() { - with_implicit_this(this_val, || { - let _ = unsafe { closure.call0() }; - }); - fired += 1; - } - } - if async_id != 0 { - unsafe { crate::js_async_hooks_provider_leave(async_id) }; + let mut call = DeferredCallbacksCall { + callbacks: rooted.as_ptr(), + len: rooted.len(), + }; + unsafe { + crate::js_async_hooks_provider_run_catching_with_this( + async_id, + this_val, + 0, + call_deferred_callbacks, + &mut call as *mut DeferredCallbacksCall as *mut std::ffi::c_void, + ) as i32 } - fired } pub(crate) fn drain_deferred_close_for(server_handle: i64, base_of: F) -> i32 @@ -101,7 +115,7 @@ where T: Send + Sync + 'static, F: FnOnce(&mut T) -> &mut HttpServer, { - let callbacks = match get_handle_mut::(server_handle) { + let (callbacks, async_id) = match get_handle_mut::(server_handle) { Some(server) => { let base = base_of(server); if !std::mem::take(&mut base.pending_close_emit) { @@ -116,28 +130,27 @@ where } } } - callbacks + let async_id = std::mem::take(&mut base.async_id); + (callbacks, async_id) } None => return 0, }; let this_value = handle_to_pointer_f64(server_handle); let scope = perry_ffi::TransientRootScope::enter(); let callbacks = scope.root_addrs(&callbacks); - let mut fired = 0; - for callback in &callbacks { - let callback = callback.get(); - if callback == 0 { - continue; - } - let closure = unsafe { JsClosure::from_raw(callback as *const RawClosureHeader) }; - if !closure.is_null() { - with_implicit_this(this_value, || unsafe { - let _ = closure.call0(); - }); - fired += 1; - } + let mut call = DeferredCallbacksCall { + callbacks: callbacks.as_ptr(), + len: callbacks.len(), + }; + unsafe { + crate::js_async_hooks_provider_run_catching_with_this( + async_id, + this_value, + 1, + call_deferred_callbacks, + &mut call as *mut DeferredCallbacksCall as *mut std::ffi::c_void, + ) as i32 } - fired } pub(super) fn server_is_active(s: &HttpServer) -> bool { // #5011 — an `unref()`ed server no longer keeps the event loop alive diff --git a/crates/perry-ext-net/src/dispatch.rs b/crates/perry-ext-net/src/dispatch.rs index 8ecfd7b829..6789e01d8d 100644 --- a/crates/perry-ext-net/src/dispatch.rs +++ b/crates/perry-ext-net/src/dispatch.rs @@ -204,36 +204,21 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option let result = match method { "write" if !args.is_empty() => { - // #5021 — call the DISTINCT, twin-free symbol directly so the - // write reaches ext-net's registry regardless of link order. - crate::js_ext_net_socket_write(handle, args[0].to_bits() as i64); - if let Some(callback) = args.iter().copied().skip(1).find(|value| { - extern "C" { - fn js_value_is_closure(value_bits: i64) -> i32; - } - js_value_is_closure(value.to_bits() as i64) != 0 - }) { - let raw = unbox_to_i64(callback) as *const RawClosureHeader; - if !raw.is_null() { - let _ = JsClosure::from_raw(raw).call0(); - } - } + crate::js_ext_net_socket_write3( + handle, + args[0], + args.get(1).copied().unwrap_or_else(undefined), + args.get(2).copied().unwrap_or_else(undefined), + ); undefined() } "end" => { - let chunk = args.first().copied().unwrap_or_else(undefined); - crate::js_ext_net_socket_end(handle, chunk.to_bits() as i64); - if let Some(callback) = args.iter().copied().find(|value| { - extern "C" { - fn js_value_is_closure(value_bits: i64) -> i32; - } - js_value_is_closure(value.to_bits() as i64) != 0 - }) { - let raw = unbox_to_i64(callback) as *const RawClosureHeader; - if !raw.is_null() { - let _ = JsClosure::from_raw(raw).call0(); - } - } + crate::js_ext_net_socket_end3( + handle, + args.first().copied().unwrap_or_else(undefined), + args.get(1).copied().unwrap_or_else(undefined), + args.get(2).copied().unwrap_or_else(undefined), + ); undefined() } "emit" if !args.is_empty() => { diff --git a/crates/perry-ext-net/src/gc_roots.rs b/crates/perry-ext-net/src/gc_roots.rs index df109d4016..35b7524cc6 100644 --- a/crates/perry-ext-net/src/gc_roots.rs +++ b/crates/perry-ext-net/src/gc_roots.rs @@ -60,6 +60,11 @@ pub(crate) fn scan_net_roots(visitor: &mut GcRootVisitor<'_>) { } } } + if let Ok(mut completions) = crate::lifecycle::socket_completions().lock() { + for (_, callback) in completions.values_mut() { + visitor.visit_i64_slot(callback); + } + } // #8259 — the pump's in-flight dispatch frames (snapshotted callbacks + // parked payloads), which the table walks above cannot see. dispatch_custody::scan(visitor); diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 4594c3459b..98ee742735 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -36,7 +36,7 @@ use bytes::{BufMut, Bytes}; use perry_ffi::{ alloc_buffer, alloc_string, gc_register_mutable_root_scanner_named, GcRootVisitor, JsClosure, - JsPromise, JsValue, RawClosureHeader, StringHeader, + JsPromise, JsValue, RawClosureHeader, StringHeader, TransientRootScope, }; use std::collections::HashMap; use std::net::SocketAddr; @@ -311,8 +311,8 @@ impl SocketState { } pub(crate) enum SocketCommand { - Write(Vec), - End, + Write(Vec, u64), + End(u64), Destroy, /// `socket.setNoDelay(enable)` — applies `TCP_NODELAY` to the live socket. /// Carried as a command (rather than a flag on `SocketState`) because the @@ -350,22 +350,16 @@ enum PendingNetEvent { /// so the path from the receive buffer to the main-thread drain handler /// (which only borrows it as `&[u8]`) stays alloc-free per read. Data(i64, Bytes), - /// Issue #1852 — peer half-closed (FIN received, `read()` returned 0). - /// Node fires `'end'` on the readable side *before* `'close'`; lots of - /// net tests block on `socket.on('end', …)` to learn the peer is done, - /// so without this the connection lifecycle never completes and the - /// test hangs. + /// Peer half-closed (FIN received); public readable-side `end` event. End(i64), - /// Completion of the writable-side shutdown requested by `socket.end()`. - /// This is distinct from `End`, which is the peer's readable-side FIN and + /// Writable-side shutdown requested by `socket.end()`, distinct from FIN; /// fires the public `end` event. - ShutdownComplete(i64), + WriteComplete(i64, u64, Option), + ShutdownComplete(i64, u64, Option), Close(i64), Error(i64, String), AbortError(i64), - /// Issue #1123 followup — accept-loop on a `net.Server` produced - /// a new client socket. Fires the server's `'connection'` - /// listeners with the new socket handle. + /// Accept-loop produced a socket for the server's `connection` listeners. /// `.0` = server id (for listener lookup) /// `.1` = socket id (passed to listeners as the arg) /// `.2` = loopback client callback has crossed a pump boundary @@ -657,10 +651,9 @@ pub unsafe extern "C" fn js_ext_net_create_server( #[no_mangle] pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, arg3: f64) { ensure_gc_scanner_registered(); - let callback_i64 = match js_net_callback_ptr(arg3) { - 0 => js_net_callback_ptr(arg2), - cb => cb, - }; + let roots = TransientRootScope::enter(); + let arg2 = roots.root_nanbox(arg2); + let arg3 = roots.root_nanbox(arg3); let path = ipc::string_value(port) .or_else(|| is_nanboxed_pointer(port).then(|| get_object_string_field(port, "path"))?); let (port_u16, host) = if path.is_some() { @@ -676,11 +669,15 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, // #2013: a numeric `port` must be an integer in [0, 65536); Node throws // RangeError [ERR_SOCKET_BAD_PORT] otherwise. js_net_validate_listen_port(port); - let host = string_from_header_i64(js_get_string_pointer_unified(arg2)) + let host = string_from_header_i64(js_get_string_pointer_unified(arg2.get())) .unwrap_or_else(|| "0.0.0.0".to_string()); (port as u16, host) }; let server_async_id = init_provider(b"TCPSERVERWRAP"); + let callback_i64 = match js_net_callback_ptr(arg3.get()) { + 0 => js_net_callback_ptr(arg2.get()), + cb => cb, + }; let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); @@ -1240,16 +1237,35 @@ pub(crate) async fn run_socket_task( rx.recv().await }; match command { - Some(SocketCommand::Write(bytes)) => { + Some(SocketCommand::Write(bytes, completion)) => { if let Err(e) = t.write_all(&bytes).await { - push_event(PendingNetEvent::Error(id, format!("{}", e))); + let msg = format!("{}", e); + if completion != 0 { + push_event(PendingNetEvent::WriteComplete( + id, + completion, + Some(msg.clone()), + )); + } + push_event(PendingNetEvent::Error(id, msg)); break; } + if completion != 0 { + push_event(PendingNetEvent::WriteComplete( + id, + completion, + None, + )); + } } - Some(SocketCommand::End) => { - let _ = t.shutdown().await; + Some(SocketCommand::End(completion)) => { + let error = t.shutdown().await.err().map(|e| e.to_string()); writable_ended = true; - push_event(PendingNetEvent::ShutdownComplete(id)); + push_event(PendingNetEvent::ShutdownComplete( + id, + completion, + error, + )); } Some(SocketCommand::SetNoDelay(enable)) => { let _ = t.set_nodelay(enable); @@ -1271,7 +1287,7 @@ pub(crate) async fn run_socket_task( } if !writable_ended { let _ = t.shutdown().await; - push_event(PendingNetEvent::ShutdownComplete(id)); + push_event(PendingNetEvent::ShutdownComplete(id, 0, None)); } push_event(PendingNetEvent::Close(id)); mark_closed(id); @@ -1324,9 +1340,16 @@ pub(crate) async fn run_socket_task( drop(window); buffer_pool::checkin(buf); match cmd { - Some(SocketCommand::Write(bytes)) => { + Some(SocketCommand::Write(bytes, completion)) => { if let Err(e) = t.write_all(&bytes).await { let msg = format!("{}", e); + if completion != 0 { + push_event(PendingNetEvent::WriteComplete( + id, + completion, + Some(msg.clone()), + )); + } if !raw_bridge::mark_terminal(id, Some(msg.clone())) { push_event(PendingNetEvent::Error(id, msg)); push_event(PendingNetEvent::Close(id)); @@ -1334,11 +1357,14 @@ pub(crate) async fn run_socket_task( mark_closed(id); break; } + if completion != 0 { + push_event(PendingNetEvent::WriteComplete(id, completion, None)); + } } - Some(SocketCommand::End) => { - let _ = t.shutdown().await; + Some(SocketCommand::End(completion)) => { + let error = t.shutdown().await.err().map(|e| e.to_string()); writable_ended = true; - push_event(PendingNetEvent::ShutdownComplete(id)); + push_event(PendingNetEvent::ShutdownComplete(id, completion, error)); } Some(SocketCommand::SetNoDelay(enable)) => { // Best-effort, matching Node: a failed setsockopt (e.g. @@ -1618,7 +1644,7 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { .ok() .and_then(|sockets| sockets.get(id).map(|socket| vec![socket.connect_async_id])) .unwrap_or_default(), - PendingNetEvent::ShutdownComplete(id) => statics::sockets() + PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets() .lock() .ok() .and_then(|sockets| sockets.get(id).map(|socket| vec![socket.shutdown_async_id])) @@ -1765,8 +1791,12 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { drop(frame); lifecycle::drain_once_listeners(id, "end"); } - PendingNetEvent::ShutdownComplete(_) => {} + PendingNetEvent::WriteComplete(_, completion, error) + | PendingNetEvent::ShutdownComplete(_, completion, error) => { + lifecycle::dispatch_socket_completion(completion, error); + } PendingNetEvent::Close(id) => { + lifecycle::drop_socket_completions(id); extern "C" { fn js_tls_client_record_closed(handle: i64); } diff --git a/crates/perry-ext-net/src/lifecycle.rs b/crates/perry-ext-net/src/lifecycle.rs index 9875eccaf0..2dee4476c3 100644 --- a/crates/perry-ext-net/src/lifecycle.rs +++ b/crates/perry-ext-net/src/lifecycle.rs @@ -20,11 +20,10 @@ //! `NativeModSig` rows live in //! `perry-codegen/src/lower_call/native_table/net_events.rs`. -use perry_ffi::{ - alloc_string, nanbox_string_bits, ArrayHeader, JsClosure, JsValue, RawClosureHeader, - StringHeader, -}; +use perry_ffi::{alloc_string, nanbox_string_bits, ArrayHeader, JsValue, StringHeader}; use std::collections::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; use crate::statics; use crate::string_from_header_i64; @@ -57,6 +56,40 @@ fn nanbox_undefined() -> f64 { f64::from_bits(TAG_UNDEFINED_BITS) } +/// Main-thread custody for write/end callbacks awaiting socket-task I/O. +pub(crate) fn socket_completions() -> &'static Mutex> { + static COMPLETIONS: OnceLock>> = + OnceLock::new(); + COMPLETIONS.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +pub(crate) unsafe fn dispatch_socket_completion(completion: u64, error: Option) { + let callback = (completion != 0) + .then(|| socket_completions().lock().unwrap().remove(&completion)) + .flatten() + .map(|(_, callback)| callback) + .unwrap_or(0); + if callback == 0 { + return; + } + let mut frame = crate::dispatch_custody::DispatchFrame::park(vec![callback]); + if let Some(message) = error { + frame.set_payload(crate::build_error_object(&message).to_bits()); + let _ = perry_ffi::JsClosure::from_raw(frame.cb(0) as *const perry_ffi::RawClosureHeader) + .call1(f64::from_bits(frame.payload_bits())); + } else { + let _ = perry_ffi::JsClosure::from_raw(frame.cb(0) as *const perry_ffi::RawClosureHeader) + .call0(); + } +} + +pub(crate) fn drop_socket_completions(socket_id: i64) { + socket_completions() + .lock() + .unwrap() + .retain(|_, (owner, _)| *owner != socket_id); +} + /// NaN-box a freshly allocated runtime string as an `f64` JS value. fn nanbox_string_value(s: &str) -> f64 { let header = alloc_string(s).as_raw(); @@ -298,10 +331,22 @@ pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) { Some(b) => b, None => return, }; + enqueue_socket_write(handle, bytes, 0); +} + +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) { s.bytes_written = s.bytes_written.saturating_add(bytes.len() as u64); - let _ = s.cmd_tx.send(crate::SocketCommand::Write(bytes)); + if s.cmd_tx + .send(crate::SocketCommand::Write(bytes, completion)) + .is_err() + && completion != 0 + { + socket_completions().lock().unwrap().remove(&completion); + } + } else if completion != 0 { + socket_completions().lock().unwrap().remove(&completion); } } @@ -318,20 +363,31 @@ pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { js_ext_net_socket_write(handle, chunk_bits); } -unsafe fn call_socket_completion(values: [f64; 3]) { +unsafe fn socket_completion(values: [f64; 3]) -> i64 { extern "C" { fn js_value_is_closure(value_bits: i64) -> i32; } - if let Some(callback) = values + values .into_iter() .find(|value| js_value_is_closure(value.to_bits() as i64) != 0) - { - const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - let raw = (callback.to_bits() & POINTER_MASK) as *const RawClosureHeader; - if !raw.is_null() { - let _ = JsClosure::from_raw(raw).call0(); - } + .map(|callback| { + const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + (callback.to_bits() & POINTER_MASK) as i64 + }) + .unwrap_or(0) +} + +fn register_socket_completion(handle: i64, callback: i64) -> u64 { + static NEXT_COMPLETION: AtomicU64 = AtomicU64::new(1); + if callback == 0 { + return 0; } + let token = NEXT_COMPLETION.fetch_add(1, Ordering::Relaxed); + socket_completions() + .lock() + .unwrap() + .insert(token, (handle, callback)); + token } /// Full Node overload for `socket.write(chunk[, encoding][, callback])`. @@ -342,8 +398,18 @@ pub unsafe extern "C" fn js_ext_net_socket_write3( encoding_or_callback: f64, callback: f64, ) { - js_ext_net_socket_write(handle, chunk.to_bits() as i64); - call_socket_completion([chunk, encoding_or_callback, callback]); + let roots = perry_ffi::TransientRootScope::enter(); + let callback = roots.root_nanbox(callback); + let encoding_or_callback = roots.root_nanbox(encoding_or_callback); + let completion = socket_completion([chunk, encoding_or_callback.get(), callback.get()]); + 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); + } + return; + }; + enqueue_socket_write(handle, bytes, completion); } /// `socket.end([data])` — optionally write a final chunk, then half-close the @@ -361,6 +427,9 @@ pub unsafe extern "C" fn js_ext_net_socket_write3( /// must reference live runtime allocations. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { + // Decode the GC-managed input before provider init can run user hooks and + // move it. Only owned bytes survive across that callback boundary. + let final_bytes = crate::jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)); let trigger = statics::sockets().lock().ok().and_then(|sockets| { sockets .get(&handle) @@ -374,13 +443,13 @@ pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { } let mut sockets = statics::sockets().lock().unwrap(); if let Some(s) = sockets.get_mut(&handle) { - if let Some(bytes) = crate::jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)) { + 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)); + let _ = s.cmd_tx.send(crate::SocketCommand::Write(bytes, 0)); } } - let _ = s.cmd_tx.send(crate::SocketCommand::End); + let _ = s.cmd_tx.send(crate::SocketCommand::End(0)); } } @@ -403,8 +472,45 @@ pub unsafe extern "C" fn js_ext_net_socket_end3( encoding_or_callback: f64, callback: f64, ) { - js_ext_net_socket_end(handle, chunk_or_callback.to_bits() as i64); - call_socket_completion([chunk_or_callback, encoding_or_callback, callback]); + let roots = perry_ffi::TransientRootScope::enter(); + let chunk_or_callback = roots.root_nanbox(chunk_or_callback); + let encoding_or_callback = roots.root_nanbox(encoding_or_callback); + let callback = roots.root_nanbox(callback); + let completion = socket_completion([ + chunk_or_callback.get(), + encoding_or_callback.get(), + callback.get(), + ]); + let completion = register_socket_completion(handle, completion); + let final_bytes = crate::jsvalue_to_socket_bytes(chunk_or_callback.get()); + let trigger = statics::sockets().lock().ok().and_then(|sockets| { + sockets + .get(&handle) + .and_then(|socket| (socket.shutdown_async_id == 0).then_some(socket.tcp_async_id)) + }); + if let Some(trigger) = trigger { + let async_id = crate::init_provider_with_trigger(b"SHUTDOWNWRAP", trigger); + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { + socket.shutdown_async_id = async_id; + } + } + 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)); + } + if socket + .cmd_tx + .send(crate::SocketCommand::End(completion)) + .is_err() + && completion != 0 + { + socket_completions().lock().unwrap().remove(&completion); + } + } else if completion != 0 { + socket_completions().lock().unwrap().remove(&completion); + } } /// `socket.destroy()` — hard close. Flags the handle destroyed (so diff --git a/crates/perry-ext-net/src/provider_lifecycle.rs b/crates/perry-ext-net/src/provider_lifecycle.rs index b6c848e8d3..7f276cd07c 100644 --- a/crates/perry-ext-net/src/provider_lifecycle.rs +++ b/crates/perry-ext-net/src/provider_lifecycle.rs @@ -51,7 +51,7 @@ pub(super) unsafe fn prepare_event_provider(ev: &PendingNetEvent) { } } } - PendingNetEvent::ShutdownComplete(id) => { + PendingNetEvent::ShutdownComplete(id, _, _) => { let trigger = statics::sockets().lock().ok().and_then(|sockets| { sockets.get(id).and_then(|socket| { (socket.shutdown_async_id == 0).then_some(socket.tcp_async_id) @@ -78,6 +78,7 @@ pub(super) fn event_provider_id(ev: &PendingNetEvent) -> u64 { PendingNetEvent::SecureConnect(id) | PendingNetEvent::Data(id, _) | PendingNetEvent::End(id) + | PendingNetEvent::WriteComplete(id, _, _) | PendingNetEvent::Error(id, _) | PendingNetEvent::AbortError(id) | PendingNetEvent::Close(id) => statics::sockets() @@ -85,7 +86,7 @@ pub(super) fn event_provider_id(ev: &PendingNetEvent) -> u64 { .ok() .and_then(|sockets| sockets.get(id).map(|socket| socket.tcp_async_id)) .unwrap_or(0), - PendingNetEvent::ShutdownComplete(id) => statics::sockets() + PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets() .lock() .ok() .and_then(|sockets| sockets.get(id).map(|socket| socket.shutdown_async_id)) diff --git a/crates/perry-ext-net/src/raw_bridge.rs b/crates/perry-ext-net/src/raw_bridge.rs index 6503fca510..9a51c88d76 100644 --- a/crates/perry-ext-net/src/raw_bridge.rs +++ b/crates/perry-ext-net/src/raw_bridge.rs @@ -105,7 +105,7 @@ extern "C" fn perry_net_raw_write(socket_id: i64, ptr: *const u8, len: usize) -> }; if let Ok(g) = statics::sockets().lock() { if let Some(s) = g.get(&socket_id) { - return i32::from(s.cmd_tx.send(SocketCommand::Write(bytes)).is_ok()); + return i32::from(s.cmd_tx.send(SocketCommand::Write(bytes, 0)).is_ok()); } } 0 diff --git a/crates/perry-ext-zlib/src/stream.rs b/crates/perry-ext-zlib/src/stream.rs index f67aaaa771..dc33937822 100644 --- a/crates/perry-ext-zlib/src/stream.rs +++ b/crates/perry-ext-zlib/src/stream.rs @@ -20,6 +20,7 @@ use perry_ffi::{ alloc_buffer, alloc_string, gc_register_mutable_root_scanner_named, notify_main_thread, BufferHeader, ErrorKind, GcRootVisitor, JsClosure, JsValue, RawClosureHeader, StringHeader, + TransientRootScope, }; use std::collections::{HashMap, HashSet, VecDeque}; use std::io::{Read, Write}; @@ -67,6 +68,7 @@ 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_native_call_method_str_key( @@ -706,7 +708,9 @@ pub(crate) unsafe fn queue_one_shot_callback( ) where F: FnOnce(&[u8]) -> std::io::Result>, { - let callback = js_zlib_validate_callback(callback_value); + let scope = TransientRootScope::enter(); + let callback_value = scope.root_nanbox(callback_value); + let _ = js_zlib_validate_callback(callback_value.get()); let data_bits = data_value.to_bits() as i64; js_zlib_validate_buffer_arg(data_bits); let result = match read_input_from_bits(data_bits) { @@ -716,6 +720,10 @@ pub(crate) unsafe fn queue_one_shot_callback( ensure_aux_pump_registered(); ensure_gc_scanner_registered(); let async_id = js_async_hooks_provider_init(b"ZLIB".as_ptr(), b"ZLIB".len()); + // Provider init delivers user hooks and may move the callback. Re-read the + // rooted value only after it returns, immediately before publishing it in + // the scanned pending queue. + let callback = js_zlib_validate_callback(callback_value.get()); statics() .lock() .unwrap() @@ -1337,6 +1345,7 @@ 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); } @@ -1368,7 +1377,10 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { } } ZlibEvent::Finish(id) => { - for cb in listeners_for(id, "finish") { + 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(); } @@ -1422,6 +1434,7 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { } } drop_buffered_stream(&mut statics().lock().unwrap(), id); + destroy_after_dispatch = event_async_id; } ZlibEvent::Callback(cb) => { if cb != 0 { @@ -1429,11 +1442,19 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { } } ZlibEvent::OneShotCallback(cb, result, async_id) => { + let scope = TransientRootScope::enter(); + let callback = scope.root_addr(cb); + // 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(cb, result); + 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); @@ -1443,11 +1464,15 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { } } drop_buffered_stream(&mut statics().lock().unwrap(), id); + destroy_after_dispatch = event_async_id; } } 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); + } } count } diff --git a/crates/perry-runtime/src/async_context.rs b/crates/perry-runtime/src/async_context.rs index 53567fcdd0..fa80f61c1c 100644 --- a/crates/perry-runtime/src/async_context.rs +++ b/crates/perry-runtime/src/async_context.rs @@ -309,6 +309,14 @@ pub fn clear_store(handle: i64) { .entries .iter() .any(|entry| entry.handle == handle) + }) || CONTEXT_GUARDS.with(|guards| { + guards.borrow().iter().any(|guard| { + matches!( + &guard.action, + ContextGuardAction::RestoreStores(saved_handle, Some(_)) + if *saved_handle == handle + ) + }) }); if was_active { HANDLE_GENERATIONS.with(|generations| { diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 9b4ddc0e52..961190f9c1 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -19,6 +19,14 @@ use crate::object::{js_object_get_field_by_name, ObjectHeader}; use crate::string::{js_string_from_bytes, StringHeader}; use crate::value::{JSValue, POINTER_MASK}; +mod provider_ffi; +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, +}; + const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; @@ -623,12 +631,21 @@ fn with_hook_callbacks( .iter() .map(|callbacks| scope.root_raw_const_ptr(callbacks.for_phase(phase))) .collect(); + let mut thrown = None; for callback in rooted { - callback.with_const_ptr::(|callback| { + let outcome = callback.with_const_ptr::(|callback| { if !callback.is_null() { - f(callback); + return crate::exception::js_call_catching(|| { + f(callback); + f64::from_bits(crate::value::TAG_UNDEFINED) + }); } + Ok(f64::from_bits(crate::value::TAG_UNDEFINED)) }); + if let Err(error) = outcome { + thrown = Some(scope.root_nanbox_f64(error)); + break; + } } let outermost = HOOK_CALLBACK_DEPTH.with(|depth| { let next = depth.get().saturating_sub(1); @@ -641,6 +658,9 @@ fn with_hook_callbacks( set_hook_enabled(index, enabled); } } + if let Some(error) = thrown { + crate::exception::js_throw(error.get_nanbox_f64()); + } } /// Model the Promise that Node uses to evaluate an ESM entry module. Perry's @@ -910,66 +930,6 @@ pub fn run_resource_scope(ids: AsyncResourceIds, completion: impl FnOnce()) { leave_resource_scope(ids.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 { - if type_ptr.is_null() { - return 0; - } - let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len)); - let resource = crate::object::js_object_alloc_null_proto(0, 0); - init_resource( - type_name, - crate::value::js_nanbox_pointer(resource as i64), - true, - ) - .async_id -} - -#[no_mangle] -pub unsafe extern "C" fn js_async_hooks_provider_init_with_trigger( - type_ptr: *const u8, - type_len: usize, - trigger_async_id: u64, -) -> u64 { - if type_ptr.is_null() { - return 0; - } - let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len)); - let resource = crate::object::js_object_alloc_null_proto(0, 0); - init_resource_with_trigger( - type_name, - crate::value::js_nanbox_pointer(resource as i64), - true, - trigger_async_id, - ) - .async_id -} - -#[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, - }); -} - -#[no_mangle] -pub extern "C" fn js_async_hooks_provider_leave(async_id: u64) { - leave_resource_scope(async_id); -} - -#[no_mangle] -pub extern "C" fn js_async_hooks_provider_destroy(async_id: u64) { - destroy(async_id); -} - pub fn enqueue_gc_destroy(async_id: u64) { if async_id != 0 { GC_DESTROY_QUEUE.lock().unwrap().push_back(async_id); @@ -1476,7 +1436,6 @@ pub fn try_async_resource_method_dispatch( ) { return None; } - let handle = resolve_async_resource_handle(receiver)?; let scope = crate::gc::RuntimeHandleScope::new(); let raw_args: Vec = if args_ptr.is_null() || args_len == 0 { Vec::new() @@ -1484,6 +1443,7 @@ 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 args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); Some(match method_name { "asyncId" => js_async_resource_async_id(handle), diff --git a/crates/perry-runtime/src/async_hooks/provider_ffi.rs b/crates/perry-runtime/src/async_hooks/provider_ffi.rs new file mode 100644 index 0000000000..b50e7f3657 --- /dev/null +++ b/crates/perry-runtime/src/async_hooks/provider_ffi.rs @@ -0,0 +1,168 @@ +//! 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, +}; + +extern "C" fn deferred_destroy_step(closure: *const crate::closure::ClosureHeader) -> f64 { + let async_id = crate::closure::js_closure_get_capture_f64(closure, 0) as u64; + let remaining = crate::closure::js_closure_get_capture_f64(closure, 1) as u32; + if remaining == 0 { + destroy(async_id); + } else { + schedule_deferred_destroy_step(async_id, remaining - 1); + } + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +fn schedule_deferred_destroy_step(async_id: u64, remaining: u32) { + crate::closure::js_register_closure_arity(deferred_destroy_step as *const u8, 0); + let scope = crate::gc::RuntimeHandleScope::new(); + let callback = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( + deferred_destroy_step as *const u8, + 2, + )); + callback.with_mut_ptr(|callback| { + crate::closure::js_closure_set_capture_f64(callback, 0, async_id as f64); + crate::closure::js_closure_set_capture_f64(callback, 1, remaining as f64); + crate::timer::js_set_immediate_callback(callback as i64); + }); +} + +/// Retire a native provider after a fixed number of check phases. libuv handle +/// close callbacks do not fire synchronously with APIs such as `unwatchFile` +/// or one-shot zlib completion, so their destroy hooks must remain observable +/// only after the corresponding close turns have run. +pub fn defer_destroy_after_check_turns(async_id: u64, check_turns: u32) { + if async_id == 0 { + return; + } + if check_turns == 0 { + destroy(async_id); + } else { + schedule_deferred_destroy_step(async_id, check_turns - 1); + } +} + +/// 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 { + if type_ptr.is_null() { + return 0; + } + let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len)); + let resource = crate::object::js_object_alloc_null_proto(0, 0); + init_resource( + type_name, + crate::value::js_nanbox_pointer(resource as i64), + true, + ) + .async_id +} + +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_provider_init_with_trigger( + type_ptr: *const u8, + type_len: usize, + trigger_async_id: u64, +) -> u64 { + if type_ptr.is_null() { + return 0; + } + let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len)); + let resource = crate::object::js_object_alloc_null_proto(0, 0); + init_resource_with_trigger( + type_name, + crate::value::js_nanbox_pointer(resource as i64), + true, + trigger_async_id, + ) + .async_id +} + +#[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, + }); +} + +#[no_mangle] +pub extern "C" fn js_async_hooks_provider_leave(async_id: u64) { + leave_resource_scope(async_id); +} + +#[no_mangle] +pub extern "C" fn js_async_hooks_provider_destroy(async_id: u64) { + destroy(async_id); +} + +#[no_mangle] +pub extern "C" fn js_async_hooks_provider_defer_destroy(async_id: u64, check_turns: u32) { + defer_destroy_after_check_turns(async_id, check_turns); +} + +/// Run an external-provider callback while guaranteeing that the provider +/// scope is restored before a JS exception resumes unwinding into generated +/// code. Rust `Drop` guards cannot provide this guarantee because Perry's JS +/// exception transport deliberately skips runtime Rust cleanup frames. +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_provider_run_catching( + async_id: u64, + 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)); + 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)), + }; + js_async_hooks_provider_leave(async_id); + if threw { + crate::exception::js_throw(result.get_nanbox_f64()); + } + result.get_nanbox_f64() +} + +/// Provider callback wrapper for external EventEmitter-style dispatch. It +/// additionally restores implicit `this` and can retire a one-shot provider +/// before propagating a JavaScript exception. +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_provider_run_catching_with_this( + async_id: u64, + this_value: f64, + destroy_after: i32, + callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64, + data: *mut std::ffi::c_void, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let this_value = scope.root_nanbox_f64(this_value); + js_async_hooks_provider_enter(async_id); + let previous_this = scope.root_nanbox_f64(crate::object::js_implicit_this_set( + this_value.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)), + }; + 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); + } + if threw { + crate::exception::js_throw(result.get_nanbox_f64()); + } + result.get_nanbox_f64() +} diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index ef4136a0e1..dfca1ece2e 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -201,7 +201,7 @@ static CP_LIVE: Mutex>> = Mutex::new(None); fn cp_init_async_resources( cp: f64, - stdin_obj: f64, + stdin_obj: Option, stdout_obj: f64, stderr_obj: f64, ) -> ( @@ -209,8 +209,14 @@ fn cp_init_async_resources( [crate::async_hooks::AsyncResourceIds; 3], ) { let process_ids = crate::async_hooks::init_resource("PROCESSWRAP", cp, true); + let stdin_ids = stdin_obj + .map(|object| crate::async_hooks::init_resource("PIPEWRAP", object, true)) + .unwrap_or(crate::async_hooks::AsyncResourceIds { + async_id: 0, + trigger_async_id: 0, + }); let pipe_ids = [ - crate::async_hooks::init_resource("PIPEWRAP", stdin_obj, true), + stdin_ids, crate::async_hooks::init_resource("PIPEWRAP", stdout_obj, true), crate::async_hooks::init_resource("PIPEWRAP", stderr_obj, true), ]; @@ -507,7 +513,8 @@ fn cp_register_live_child_parts( for (_, stream, _) in &extra_pipes { cp_set_field(*stream, b"__cpHandle", handle_f); } - let (process_ids, pipe_ids) = cp_init_async_resources(cp, stdin_obj, stdout_obj, stderr_obj); + let (process_ids, pipe_ids) = + cp_init_async_resources(cp, Some(stdin_obj), stdout_obj, stderr_obj); // For fork, keep a clone of the IPC socket for send/disconnect; the reader // thread owns the original. @@ -1120,8 +1127,7 @@ pub(super) fn cp_exec_async( exceeded: false, timed_out: false, }); - let (process_ids, pipe_ids) = - cp_init_async_resources(cp, TAG_NULL_F64, stdout_obj, stderr_obj); + let (process_ids, pipe_ids) = cp_init_async_resources(cp, None, stdout_obj, stderr_obj); { let mut guard = cp_live_lock(); @@ -1637,8 +1643,10 @@ pub(super) fn cp_async_scope_for_target( child .pipe_bits .iter() - .position(|bits| *bits == target_bits) - .map(|index| child.pipe_ids[index]) + .enumerate() + .filter(|(index, bits)| *index != 0 || **bits != TAG_NULL_F64.to_bits()) + .find(|(_, bits)| **bits == target_bits) + .map(|(index, _)| child.pipe_ids[index]) } // ============================================================================ diff --git a/crates/perry-runtime/src/dns.rs b/crates/perry-runtime/src/dns.rs index 76a9fa7293..ea60a53806 100644 --- a/crates/perry-runtime/src/dns.rs +++ b/crates/perry-runtime/src/dns.rs @@ -56,6 +56,8 @@ const RESOLVER_RESOLVE_METHODS: &[&str] = &[ "reverse", ]; const RESOLVER_SERVERS_FIELD: &str = "__dns_servers"; +const RESOLVER_TIMEOUT_FIELD: &str = "__dns_timeout"; +const RESOLVER_TRIES_FIELD: &str = "__dns_tries"; #[derive(Clone, Copy)] pub(crate) enum RecordKind { @@ -1244,22 +1246,66 @@ fn method_value(name: &str) -> f64 { js_nanbox_pointer(closure as i64) } -fn resolver_object(initial_servers: Vec) -> *mut ObjectHeader { - let method_count = RESOLVER_CONTROL_METHODS.len() + RESOLVER_RESOLVE_METHODS.len() + 1; - let obj = js_object_alloc(0, method_count as u32); - js_object_set_field_by_name( - obj, - key(RESOLVER_SERVERS_FIELD), - servers_array_value(&initial_servers), - ); +fn resolver_object(initial_servers: Vec, options: f64) -> *mut ObjectHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let options = scope.root_nanbox_f64(options); + let timeout_key = scope.root_string_ptr(key("timeout")); + let timeout = resolver_object_from_value(options.get_nanbox_f64()) + .map(|object| { + timeout_key.with_mut_ptr::(|key| { + crate::object::js_object_get_field_by_name_f64(object, key) + }) + }) + .unwrap_or_else(undefined_value); + let timeout = scope.root_nanbox_f64(timeout); + let tries_key = scope.root_string_ptr(key("tries")); + let tries = resolver_object_from_value(options.get_nanbox_f64()) + .map(|object| { + tries_key.with_mut_ptr::(|key| { + crate::object::js_object_get_field_by_name_f64(object, key) + }) + }) + .unwrap_or_else(undefined_value); + let tries = scope.root_nanbox_f64(tries); + let method_count = RESOLVER_CONTROL_METHODS.len() + RESOLVER_RESOLVE_METHODS.len() + 3; + let obj = scope.root_raw_mut_ptr(js_object_alloc(0, method_count as u32)); + let servers = scope.root_nanbox_f64(servers_array_value(&initial_servers)); + let servers_key = scope.root_string_ptr(key(RESOLVER_SERVERS_FIELD)); + obj.with_mut_ptr::(|object| { + servers_key.with_mut_ptr::(|key| { + js_object_set_field_by_name(object, key, servers.get_nanbox_f64()); + }); + }); + let timeout_key = scope.root_string_ptr(key(RESOLVER_TIMEOUT_FIELD)); + obj.with_mut_ptr::(|object| { + timeout_key.with_mut_ptr::(|key| { + js_object_set_field_by_name(object, key, timeout.get_nanbox_f64()); + }); + }); + let tries_key = scope.root_string_ptr(key(RESOLVER_TRIES_FIELD)); + obj.with_mut_ptr::(|object| { + tries_key.with_mut_ptr::(|key| { + js_object_set_field_by_name(object, key, tries.get_nanbox_f64()); + }); + }); for method in RESOLVER_CONTROL_METHODS { - js_object_set_field_by_name(obj, key(method), method_value(method)); + let value = scope.root_nanbox_f64(method_value(method)); + let field_key = scope.root_string_ptr(key(method)); + obj.with_mut_ptr::(|object| { + field_key.with_mut_ptr::(|field_key| { + js_object_set_field_by_name(object, field_key, value.get_nanbox_f64()); + }); + }); } for method in RESOLVER_RESOLVE_METHODS { - js_object_set_field_by_name(obj, key(method), method_value(method)); + let value = scope.root_nanbox_f64(method_value(method)); + let field_key = scope.root_string_ptr(key(method)); + obj.with_mut_ptr::(|object| { + field_key.with_mut_ptr::(|field_key| { + js_object_set_field_by_name(object, field_key, value.get_nanbox_f64()); + }); + }); } - let scope = crate::gc::RuntimeHandleScope::new(); - let obj = scope.root_raw_mut_ptr(obj); let (_, obj_ptr) = obj.across_mut::(|| { obj.with_mut_ptr::(|obj_ptr| { let _ = crate::async_hooks::init_resource( diff --git a/crates/perry-runtime/src/dns/ffi.rs b/crates/perry-runtime/src/dns/ffi.rs index fe239d503c..41311f4665 100644 --- a/crates/perry-runtime/src/dns/ffi.rs +++ b/crates/perry-runtime/src/dns/ffi.rs @@ -257,13 +257,13 @@ pub extern "C" fn js_dns_get_default_result_order(_args: i64) -> f64 { } #[no_mangle] -pub extern "C" fn js_dns_resolver_new(_args: i64) -> f64 { - boxed_pointer(resolver_object(stored_servers()) as *const u8) +pub extern "C" fn js_dns_resolver_new(args: i64) -> f64 { + boxed_pointer(resolver_object(stored_servers(), first_arg(args)) as *const u8) } #[no_mangle] -pub extern "C" fn js_dns_promises_resolver_new(_args: i64) -> f64 { - boxed_pointer(resolver_object(stored_promise_servers()) as *const u8) +pub extern "C" fn js_dns_promises_resolver_new(args: i64) -> f64 { + boxed_pointer(resolver_object(stored_promise_servers(), first_arg(args)) as *const u8) } #[no_mangle] diff --git a/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs b/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs index 905cf6cae6..1848fc5308 100644 --- a/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs +++ b/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs @@ -83,6 +83,7 @@ struct WatchFileState { path: String, object_value: f64, timer_id: i64, + async_id: u64, bigint: bool, previous: Option, listeners: HashMap>, @@ -602,6 +603,10 @@ fn close_watch_file_state(id: usize) { let removed = WATCH_FILE_STATES.with(|states| states.borrow_mut().remove(&id)); if let Some(state) = removed { crate::timer::clearInterval(state.timer_id); + // Node retires the underlying uv_fs_poll handle from its close + // callback, after three check phases rather than synchronously from + // unwatchFile(). Preserve that observable destroy-hook timing. + crate::async_hooks::defer_destroy_after_check_turns(state.async_id, 3); WATCH_FILE_PATHS.with(|paths| { paths.borrow_mut().remove(&state.path); }); @@ -1530,7 +1535,7 @@ pub extern "C" fn js_fs_watch_file(path_value: f64, arg1: f64, arg2: f64) -> f64 } let id = next_watch_id(); let object_value = build_stat_watcher_object(id); - let _ = crate::async_hooks::init_resource("STATWATCHER", object_value, true); + let async_id = crate::async_hooks::init_resource("STATWATCHER", object_value, true).async_id; let interval = option_interval_ms(options_value); let persistent = option_bool_default_local(options_value, b"persistent", true); let bigint = unsafe { options_bool_field(options_value, b"bigint") }; @@ -1548,6 +1553,7 @@ pub extern "C" fn js_fs_watch_file(path_value: f64, arg1: f64, arg2: f64) -> f64 path: path.clone(), object_value, timer_id, + async_id, bigint, previous: stat_snapshot(&path), listeners, diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 3068562925..2b0552a4ed 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -442,7 +442,6 @@ pub fn gen_gc_enabled() -> bool { // decision that hasn't been made". fn gc_force_evacuate_enabled() -> bool { - #[cfg(test)] if let Some(forced) = knob_overrides::FORCE_EVACUATE_TEST_OVERRIDE.with(std::cell::Cell::get) { return forced; } @@ -456,7 +455,6 @@ fn gc_force_evacuate_enabled() -> bool { } fn gc_verify_evacuation_enabled() -> bool { - #[cfg(test)] if let Some(forced) = knob_overrides::VERIFY_EVACUATION_TEST_OVERRIDE.with(std::cell::Cell::get) { return forced; @@ -488,11 +486,10 @@ fn gc_verify_evacuation_enabled() -> bool { /// `gc::tests::evacuation::explicit_gc_under_forced_evacuation_runs_a_moving_minor`, /// whose comment says in as many words that "an `EnvVarGuard` would set a /// process-global every other test in this crate shares". -#[cfg(test)] pub(super) mod knob_overrides { use std::cell::Cell; - thread_local! { + crate::perry_thread_local! { pub(super) static FORCE_EVACUATE_TEST_OVERRIDE: Cell> = const { Cell::new(None) }; pub(super) static VERIFY_EVACUATION_TEST_OVERRIDE: Cell> = @@ -500,14 +497,17 @@ pub(super) mod knob_overrides { } /// Pin `gc_force_evacuate_enabled()` for this thread only. + #[cfg(test)] pub(crate) struct ForcedEvacuationTestGuard(Option); + #[cfg(test)] impl ForcedEvacuationTestGuard { pub(crate) fn on() -> Self { Self(FORCE_EVACUATE_TEST_OVERRIDE.with(|c| c.replace(Some(true)))) } } + #[cfg(test)] impl Drop for ForcedEvacuationTestGuard { fn drop(&mut self) { FORCE_EVACUATE_TEST_OVERRIDE.with(|c| c.set(self.0)); @@ -515,14 +515,17 @@ pub(super) mod knob_overrides { } /// Pin `gc_verify_evacuation_enabled()` for this thread only. + #[cfg(test)] pub(crate) struct VerifyEvacuationTestGuard(Option); + #[cfg(test)] impl VerifyEvacuationTestGuard { pub(crate) fn on() -> Self { Self(VERIFY_EVACUATION_TEST_OVERRIDE.with(|c| c.replace(Some(true)))) } } + #[cfg(test)] impl Drop for VerifyEvacuationTestGuard { fn drop(&mut self) { VERIFY_EVACUATION_TEST_OVERRIDE.with(|c| c.set(self.0)); @@ -530,6 +533,25 @@ pub(super) mod knob_overrides { } } +/// Test-only control surface used by separately compiled extension-crate +/// tests. The override is thread-local, so it cannot race unrelated tests the +/// way mutating `PERRY_GC_FORCE_EVACUATE` did. `enabled`: `1` = on, `0` = off, +/// any negative value = clear. Returns the previous state using the same +/// encoding. +#[doc(hidden)] +pub fn js_gc_force_evacuation_test_override(enabled: i32) -> i32 { + let next = match enabled { + 1.. => Some(true), + 0 => Some(false), + _ => None, + }; + knob_overrides::FORCE_EVACUATE_TEST_OVERRIDE.with(|cell| match cell.replace(next) { + Some(true) => 1, + Some(false) => 0, + None => -1, + }) +} + #[cfg(test)] thread_local! { /// `PERRY_GC_SCAVENGE` — **ON by default since #7056**, kill switch diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index 015ccea408..97a31561ee 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1265,10 +1265,15 @@ fn dynamic_import_javascript_data_url(specifier: &str) -> Option { .unwrap_or(expression.trim()); let scope = crate::gc::RuntimeHandleScope::new(); let value = scope.root_nanbox_f64(crate::node_vm::eval_dynamic_module_expression(expression)); - let namespace = crate::object::js_object_alloc_null_proto(0, 1); - let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - crate::object::js_object_set_field_by_name(namespace, key, value.get_nanbox_f64()); - Some(js_nanbox_pointer(namespace as i64)) + let namespace = scope.root_raw_mut_ptr(crate::object::js_object_alloc_null_proto(0, 1)); + let key = scope.root_string_ptr(js_string_from_bytes(name.as_ptr(), name.len() as u32)); + let namespace_value = namespace.with_mut_ptr::(|object| { + key.with_mut_ptr::(|key| { + crate::object::js_object_set_field_by_name(object, key, value.get_nanbox_f64()); + }); + js_nanbox_pointer(object as i64) + }); + Some(namespace_value) } /// Codegen entry for the unresolved / no-match dynamic-`import()` fallthrough diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index 6f8588d421..aee7a3b187 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -134,12 +134,14 @@ pub extern "C" fn js_event_emitter_async_resource_subclass_init(this: f64, optio crate::string::js_string_from_bytes(default_name.as_ptr(), default_name.len() as u32); name = f64::from_bits(JSValue::string_ptr(name_ptr).bits()); } + let name_handle = scope.root_nanbox_f64(name); let async_options = if options_value.is_any_string() { f64::from_bits(crate::value::TAG_UNDEFINED) } else { options_handle.get_nanbox_f64() }; - let resource = crate::async_hooks::js_async_resource_new(name, async_options); + let resource = + crate::async_hooks::js_async_resource_new(name_handle.get_nanbox_f64(), async_options); let obj = this_handle.get_nanbox_f64(); let raw = raw_ptr_from_value(obj); crate::async_hooks::js_async_resource_set_event_emitter(resource, raw as i64); diff --git a/crates/perry-runtime/src/node_stream_constructors/pipeline.rs b/crates/perry-runtime/src/node_stream_constructors/pipeline.rs index f8b370a622..27ab7fa395 100644 --- a/crates/perry-runtime/src/node_stream_constructors/pipeline.rs +++ b/crates/perry-runtime/src/node_stream_constructors/pipeline.rs @@ -231,11 +231,6 @@ pub extern "C" fn js_node_stream_finished(args: *const crate::array::ArrayHeader if let Some(signal) = options_signal(options) { add_finished_signal_abort_listener(stream, signal, callback); } - if get_hidden_value(options, hidden_key(b"cleanup")) - .is_some_and(|v| crate::value::js_is_truthy(v) != 0) - { - add_finished_cleanup_completion_listener(stream, callback); - } f64::from_bits(TAG_UNDEFINED) } diff --git a/crates/perry-runtime/src/node_stream_dispatch.rs b/crates/perry-runtime/src/node_stream_dispatch.rs index 913fcb8e9e..150ebb834e 100644 --- a/crates/perry-runtime/src/node_stream_dispatch.rs +++ b/crates/perry-runtime/src/node_stream_dispatch.rs @@ -263,7 +263,9 @@ enum EventEmitterAsyncResourceBacking { } fn event_emitter_async_resource_backing(receiver: f64) -> Option { - let bits = receiver.to_bits(); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let bits = receiver.get_nanbox_f64().to_bits(); if bits >> 48 == 0x7FFD { let handle = (bits & crate::value::POINTER_MASK) as i64; if crate::object::event_emitter_async_resource_handle_probe() @@ -273,8 +275,11 @@ fn event_emitter_async_resource_backing(receiver: f64) -> Option(|key| { + js_object_get_field_by_name_f64(raw as *const ObjectHeader, key) + }); if value.to_bits() >> 48 == 0x7FFD { let resource = (value.to_bits() & crate::value::POINTER_MASK) as i64; if crate::async_hooks::is_async_resource_handle(resource) { @@ -353,6 +358,8 @@ extern "C" fn ns_ee_async_resource_getter(closure: *const ClosureHeader) -> f64 /// EventEmitter methods remain available, while `emit` and the resource /// accessors enforce Node's private-brand receiver validation. pub(crate) unsafe fn install_event_emitter_async_resource_prototype(proto: *mut ObjectHeader) { + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_raw_mut_ptr(proto); crate::closure::js_register_closure_rest(ns_ee_async_resource_emit_rest as *const u8, 1); crate::closure::js_register_closure_arity(ns_ee_async_resource_destroy as *const u8, 0); crate::closure::js_register_closure_arity(ns_ee_async_resource_getter as *const u8, 0); @@ -360,16 +367,26 @@ pub(crate) unsafe fn install_event_emitter_async_resource_prototype(proto: *mut let install_method = |name: &str, function: *const u8| { let closure = js_closure_alloc(function, 1); crate::closure::js_closure_set_capture_ptr(closure, 0, crate::value::TAG_UNDEFINED as i64); - js_object_set_field_by_name( - proto, - hidden_key(name.as_bytes()), - f64::from_bits(JSValue::pointer(closure as *const u8).bits()), - ); - crate::object::set_builtin_property_attrs( - proto as usize, - name.to_string(), - crate::object::PropertyAttrs::new(true, false, true), - ); + let closure = scope.root_raw_mut_ptr(closure); + let key = scope.root_string_ptr(hidden_key(name.as_bytes())); + proto.with_mut_ptr::(|proto| { + key.with_const_ptr::(|key| { + closure.with_const_ptr::(|closure| { + js_object_set_field_by_name( + proto, + key, + f64::from_bits(JSValue::pointer(closure as *const u8).bits()), + ); + }); + }); + }); + proto.with_mut_ptr::(|proto| { + crate::object::set_builtin_property_attrs( + proto as usize, + name.to_string(), + crate::object::PropertyAttrs::new(true, false, true), + ); + }); }; install_method("emit", ns_ee_async_resource_emit_rest as *const u8); install_method("emitDestroy", ns_ee_async_resource_destroy as *const u8); @@ -382,17 +399,30 @@ pub(crate) unsafe fn install_event_emitter_async_resource_prototype(proto: *mut let closure = js_closure_alloc(ns_ee_async_resource_getter as *const u8, 2); crate::closure::js_closure_set_capture_ptr(closure, 0, crate::value::TAG_UNDEFINED as i64); crate::closure::js_closure_set_capture_ptr(closure, 1, operation); - let key = hidden_key(name.as_bytes()); - js_object_set_field_by_name(proto, key, f64::from_bits(crate::value::TAG_UNDEFINED)); - crate::object::set_builtin_accessor_descriptor( - proto as usize, - name.to_string(), - crate::object::AccessorDescriptor { - get: JSValue::pointer(closure as *const u8).bits(), - set: 0, - }, - crate::object::PropertyAttrs::new(true, false, true), - ); + let closure = scope.root_raw_mut_ptr(closure); + let key = scope.root_string_ptr(hidden_key(name.as_bytes())); + proto.with_mut_ptr::(|proto| { + key.with_const_ptr::(|key| { + js_object_set_field_by_name( + proto, + key, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + }); + }); + proto.with_mut_ptr::(|proto| { + closure.with_const_ptr::(|closure| { + crate::object::set_builtin_accessor_descriptor( + proto as usize, + name.to_string(), + crate::object::AccessorDescriptor { + get: JSValue::pointer(closure as *const u8).bits(), + set: 0, + }, + crate::object::PropertyAttrs::new(true, false, true), + ); + }); + }); } } @@ -400,6 +430,9 @@ pub(crate) unsafe fn install_event_emitter_async_resource_instance_methods( obj: *mut ObjectHeader, this_value: f64, ) { + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let this_value = scope.root_nanbox_f64(this_value); crate::closure::js_register_closure_rest(ns_ee_async_resource_emit_rest as *const u8, 1); crate::closure::js_register_closure_arity(ns_ee_async_resource_destroy as *const u8, 0); crate::closure::js_register_closure_arity(ns_ee_async_resource_getter as *const u8, 0); @@ -408,12 +441,24 @@ pub(crate) unsafe fn install_event_emitter_async_resource_instance_methods( ("emitDestroy", ns_ee_async_resource_destroy as *const u8), ] { let closure = js_closure_alloc(function, 1); - crate::closure::js_closure_set_capture_ptr(closure, 0, this_value.to_bits() as i64); - js_object_set_field_by_name( - obj, - hidden_key(name.as_bytes()), - f64::from_bits(JSValue::pointer(closure as *const u8).bits()), + crate::closure::js_closure_set_capture_ptr( + closure, + 0, + this_value.get_nanbox_f64().to_bits() as i64, ); + let closure = scope.root_raw_mut_ptr(closure); + let key = scope.root_string_ptr(hidden_key(name.as_bytes())); + obj.with_mut_ptr::(|obj| { + key.with_const_ptr::(|key| { + closure.with_const_ptr::(|closure| { + js_object_set_field_by_name( + obj, + key, + f64::from_bits(JSValue::pointer(closure as *const u8).bits()), + ); + }); + }); + }); } // Source-compiled subclasses are plain runtime objects rather than @@ -426,19 +471,32 @@ pub(crate) unsafe fn install_event_emitter_async_resource_instance_methods( ("asyncResource", 2), ] { let closure = js_closure_alloc(ns_ee_async_resource_getter as *const u8, 2); - crate::closure::js_closure_set_capture_ptr(closure, 0, this_value.to_bits() as i64); - crate::closure::js_closure_set_capture_ptr(closure, 1, operation); - let key = hidden_key(name.as_bytes()); - js_object_set_field_by_name(obj, key, f64::from_bits(crate::value::TAG_UNDEFINED)); - crate::object::set_builtin_accessor_descriptor( - obj as usize, - name.to_string(), - crate::object::AccessorDescriptor { - get: JSValue::pointer(closure as *const u8).bits(), - set: 0, - }, - crate::object::PropertyAttrs::new(true, false, true), + crate::closure::js_closure_set_capture_ptr( + closure, + 0, + this_value.get_nanbox_f64().to_bits() as i64, ); + crate::closure::js_closure_set_capture_ptr(closure, 1, operation); + let closure = scope.root_raw_mut_ptr(closure); + let key = scope.root_string_ptr(hidden_key(name.as_bytes())); + obj.with_mut_ptr::(|obj| { + key.with_const_ptr::(|key| { + js_object_set_field_by_name(obj, key, f64::from_bits(crate::value::TAG_UNDEFINED)); + }); + }); + obj.with_mut_ptr::(|obj| { + closure.with_const_ptr::(|closure| { + crate::object::set_builtin_accessor_descriptor( + obj as usize, + name.to_string(), + crate::object::AccessorDescriptor { + get: JSValue::pointer(closure as *const u8).bits(), + set: 0, + }, + crate::object::PropertyAttrs::new(true, false, true), + ); + }); + }); } } diff --git a/crates/perry-runtime/src/node_submodules/fs_promises.rs b/crates/perry-runtime/src/node_submodules/fs_promises.rs index abf3d92f4e..5b1a2251a7 100644 --- a/crates/perry-runtime/src/node_submodules/fs_promises.rs +++ b/crates/perry-runtime/src/node_submodules/fs_promises.rs @@ -129,12 +129,16 @@ pub(crate) extern "C" fn thunk_fs_promises_open( match catch_fs_promises_throw(|| { match unsafe { crate::fs::js_fs_filehandle_open_result(path, flags) } { Ok(handle) => { - let promise = crate::fs::promise_value_fs(handle); - let ids = crate::async_hooks::init_resource("FILEHANDLE", handle, true); + let scope = crate::gc::RuntimeHandleScope::new(); + let handle = scope.root_nanbox_f64(handle); + let promise = + scope.root_nanbox_f64(crate::fs::promise_value_fs(handle.get_nanbox_f64())); + let ids = + crate::async_hooks::init_resource("FILEHANDLE", handle.get_nanbox_f64(), true); // FILEHANDLE is owned by the public handle and remains live // after open; Node does not emit before/after/destroy for it. let _ = ids; - promise + promise.get_nanbox_f64() } Err(err_val) => crate::fs::promise_rejected_fs(err_val), } @@ -399,8 +403,11 @@ pub(crate) extern "C" fn thunk_fs_promises_opendir( ) -> f64 { match crate::fs::js_fs_opendir_value_with_path(path) { Ok(directory) => { - let _ = crate::async_hooks::init_resource("DIRHANDLE", directory, true); - crate::fs::promise_value_fs(directory) + let scope = crate::gc::RuntimeHandleScope::new(); + let directory = scope.root_nanbox_f64(directory); + let _ = + crate::async_hooks::init_resource("DIRHANDLE", directory.get_nanbox_f64(), true); + crate::fs::promise_value_fs(directory.get_nanbox_f64()) } Err(err) => crate::fs::promise_rejected_fs(err), } diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index b0927a44e9..e45fa4ecd8 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -344,7 +344,7 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { let raw = value_addr(value); let matched = if method == "AsyncResource" { crate::async_hooks::resolve_async_resource_handle(raw as i64).is_some() - || (raw >= crate::value::addr_class::HANDLE_BAND_MAX + || (crate::value::addr_class::is_plausible_heap_addr(raw) && ordinary_has_instance_prototype_walk(value, type_ref)) } else { let candidate = small_native_handle_id(value).unwrap_or(raw as i64); @@ -357,7 +357,7 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { }) }; native - || (raw >= crate::value::addr_class::HANDLE_BAND_MAX + || (crate::value::addr_class::is_plausible_heap_addr(raw) && ordinary_has_instance_prototype_walk(value, type_ref)) }; return f64::from_bits(if matched { diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs index 12c923bd97..7eaee3cd6a 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs @@ -155,8 +155,13 @@ pub(crate) unsafe fn nm_dispatch_async_hooks( ("async_hooks", "AsyncLocalStorage") | ("async_hooks", "AsyncResource") => { let message = format!("Class constructor {method_name} cannot be invoked without 'new'"); - let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); - let err = crate::error::js_typeerror_new(msg); + let scope = crate::gc::RuntimeHandleScope::new(); + let msg = scope.root_string_ptr(crate::string::js_string_from_bytes( + message.as_ptr(), + message.len() as u32, + )); + let err = msg + .with_mut_ptr::(|msg| crate::error::js_typeerror_new(msg)); crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } ("async_hooks", "createHook") => { diff --git a/crates/perry-runtime/src/promise/assimilate.rs b/crates/perry-runtime/src/promise/assimilate.rs index 122aa716d7..e499d6f52d 100644 --- a/crates/perry-runtime/src/promise/assimilate.rs +++ b/crates/perry-runtime/src/promise/assimilate.rs @@ -270,16 +270,26 @@ extern "C" fn native_promise_adoption_job(closure: *const crate::closure::Closur Some((value, is_error)) => { // Null-closure AsyncStep = a pure propagation task: the runner // resolves/rejects `outer` with `value` on the next tick. + let scope = crate::gc::RuntimeHandleScope::new(); + let outer_handle = scope.root_raw_mut_ptr(outer); + let value_handle = scope.root_nanbox_f64(value); + let context = capture_context(); + let ((async_id, trigger_async_id), outer) = + outer_handle.across_mut::(|| { + outer_handle.with_mut_ptr::(|outer| unsafe { + ((*outer).async_id, (*outer).trigger_async_id) + }) + }); TASK_QUEUE.with(|q| { q.borrow_mut().push_back(Task::AsyncStep( std::ptr::null(), - value, + value_handle.get_nanbox_f64(), outer, is_error, - capture_context(), + context, std::ptr::null_mut(), - unsafe { (*outer).async_id }, - unsafe { (*outer).trigger_async_id }, + async_id, + trigger_async_id, )); }); crate::event_pump::js_notify_promise_progress(); diff --git a/crates/perry-runtime/src/promise/async_step.rs b/crates/perry-runtime/src/promise/async_step.rs index 3286ac0d4d..08af32de52 100644 --- a/crates/perry-runtime/src/promise/async_step.rs +++ b/crates/perry-runtime/src/promise/async_step.rs @@ -678,6 +678,14 @@ pub extern "C" fn js_async_step_chain(value: f64, step_closure: ClosurePtr) -> * } else { unsafe { ((*next).async_id, (*next).trigger_async_id) } }; + let next = { + let value = next_handle.get_nanbox_f64(); + if value.to_bits() == crate::value::TAG_UNDEFINED { + std::ptr::null_mut() + } else { + crate::value::js_nanbox_get_pointer(value) as *mut Promise + } + }; crate::r#box::retain_async_box_activation(trap.box_activation); TASK_QUEUE.with(|q| { q.borrow_mut().push_back(Task::AsyncStep( diff --git a/crates/perry-runtime/src/promise/microtasks.rs b/crates/perry-runtime/src/promise/microtasks.rs index db67ddb45a..12df3176fe 100644 --- a/crates/perry-runtime/src/promise/microtasks.rs +++ b/crates/perry-runtime/src/promise/microtasks.rs @@ -415,11 +415,14 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { CURRENT_MICROTASK_VALUE.with(|c| c.set(value)); CURRENT_MICROTASK_NEXT.with(|c| c.set((*promise).next)); crate::async_hooks::before_promise(async_id, trigger_async_id); - if !(*promise).next.is_null() { + let promise = rooted_promise(&task_promise_handle); + let value = task_value_handle.get_nanbox_f64(); + let next = (*promise).next; + if !next.is_null() { if is_fulfilled { - js_promise_resolve((*promise).next, value); + js_promise_resolve(next, value); } else { - js_promise_reject((*promise).next, value); + js_promise_reject(next, value); } } crate::async_hooks::after_promise(async_id); @@ -611,6 +614,8 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { // source promise — now dispatch directly: invoke the // stored callback, propagate the result to `next`. if callback.is_null() { + let next = rooted_promise(&next_handle); + let value = value_handle.get_nanbox_f64(); if !next.is_null() { if is_fulfilled { js_promise_resolve(next, value); @@ -658,7 +663,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { } else { None }; - crate::v8::promise_hook_before(next); + crate::v8::promise_hook_before(rooted_promise(&next_handle)); let callback = rooted_closure(&callback_handle); let result = crate::closure::js_closure_call1(callback, value_handle.get_nanbox_f64()); @@ -780,6 +785,9 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { // here with two fewer indirections (closure alloc + // closure call). if step_closure.is_null() { + crate::async_hooks::before_promise(step_async_id, step_trigger_id); + let next = rooted_promise(&next_handle); + let value = value_handle.get_nanbox_f64(); if !next.is_null() { if is_error { js_promise_reject(next, value); @@ -787,6 +795,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { js_promise_resolve(next, value); } } + crate::async_hooks::after_promise(step_async_id); restore_microtask_context(); if !box_activation.is_null() { pop_async_box_execution_ref(box_activation); diff --git a/crates/perry-runtime/src/promise/then.rs b/crates/perry-runtime/src/promise/then.rs index 1601cc99b7..9f9eb55cb2 100644 --- a/crates/perry-runtime/src/promise/then.rs +++ b/crates/perry-runtime/src/promise/then.rs @@ -1759,16 +1759,25 @@ extern "C" fn finally_passthrough_fulfill( // () => value)`, whose then-return propagation costs one more tick // than this passthrough's old direct `js_promise_resolve(next, v)`. // Settle `next` via a propagation task instead. + let scope = crate::gc::RuntimeHandleScope::new(); + let next_handle = scope.root_raw_mut_ptr(next); + let value_handle = scope.root_nanbox_f64(value); + let context = capture_context(); + let ((async_id, trigger_async_id), next) = next_handle.across_mut::(|| { + next_handle.with_mut_ptr::(|next| unsafe { + ((*next).async_id, (*next).trigger_async_id) + }) + }); TASK_QUEUE.with(|q| { q.borrow_mut().push_back(Task::AsyncStep( std::ptr::null(), - value, + value_handle.get_nanbox_f64(), next, false, - capture_context(), + context, std::ptr::null_mut(), - unsafe { (*next).async_id }, - unsafe { (*next).trigger_async_id }, + async_id, + trigger_async_id, )); }); crate::event_pump::js_notify_promise_progress(); @@ -1787,16 +1796,25 @@ extern "C" fn finally_passthrough_reject( let reason = js_closure_get_capture_f64(closure, 1); if !next.is_null() { // Same extra tick as the fulfilled passthrough (V8 hop parity). + let scope = crate::gc::RuntimeHandleScope::new(); + let next_handle = scope.root_raw_mut_ptr(next); + let reason_handle = scope.root_nanbox_f64(reason); + let context = capture_context(); + let ((async_id, trigger_async_id), next) = next_handle.across_mut::(|| { + next_handle.with_mut_ptr::(|next| unsafe { + ((*next).async_id, (*next).trigger_async_id) + }) + }); TASK_QUEUE.with(|q| { q.borrow_mut().push_back(Task::AsyncStep( std::ptr::null(), - reason, + reason_handle.get_nanbox_f64(), next, true, - capture_context(), + context, std::ptr::null_mut(), - unsafe { (*next).async_id }, - unsafe { (*next).trigger_async_id }, + async_id, + trigger_async_id, )); }); crate::event_pump::js_notify_promise_progress(); diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index ef88bdd357..db09ed5520 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1084,20 +1084,30 @@ fn async_resource_handle_from_value(value: f64) -> Option { } fn set_handle_property(target: f64, key: f64, value: f64) -> Option { - if let Some(handle) = async_resource_handle_from_value(target) { - if unsafe { crate::symbol::js_is_symbol(key) } != 0 { - unsafe { crate::symbol::js_object_set_symbol_property(target, key, value) }; + let scope = crate::gc::RuntimeHandleScope::new(); + let target = scope.root_nanbox_f64(target); + let key = scope.root_nanbox_f64(key); + let value = scope.root_nanbox_f64(value); + if let Some(handle) = async_resource_handle_from_value(target.get_nanbox_f64()) { + if unsafe { crate::symbol::js_is_symbol(key.get_nanbox_f64()) } != 0 { + unsafe { + crate::symbol::js_object_set_symbol_property( + target.get_nanbox_f64(), + key.get_nanbox_f64(), + value.get_nanbox_f64(), + ) + }; return Some(true); } - let Some(name) = key_to_rust_string(key) else { + let Some(name) = key_to_rust_string(key.get_nanbox_f64()) else { return Some(false); }; - crate::object::handle_expando::handle_expando_set(handle, &name, value); + crate::object::handle_expando::handle_expando_set(handle, &name, value.get_nanbox_f64()); return Some(true); } - let handle = small_handle_from_value(target)?; - let Some(name) = key_to_rust_string(key) else { + let handle = small_handle_from_value(target.get_nanbox_f64())?; + let Some(name) = key_to_rust_string(key.get_nanbox_f64()) else { // A SYMBOL-keyed write on a small native handle (e.g. the // @hono/node-server `incoming[wrapBodyStream] = true` on the HTTP // IncomingMessage handle). The handle is not a heap ObjectHeader, so @@ -1106,14 +1116,20 @@ fn set_handle_property(target: f64, key: f64, value: f64) -> Option { // object) and report success. Returning `Some(false)` here made // strict-mode assignment throw `TypeError: Cannot assign to read only // property` and 500 every POST/PUT served by Hono's node adapter. - if unsafe { crate::symbol::js_is_symbol(key) } != 0 { - unsafe { crate::symbol::js_object_set_symbol_property(target, key, value) }; + if unsafe { crate::symbol::js_is_symbol(key.get_nanbox_f64()) } != 0 { + unsafe { + crate::symbol::js_object_set_symbol_property( + target.get_nanbox_f64(), + key.get_nanbox_f64(), + value.get_nanbox_f64(), + ) + }; return Some(true); } return Some(false); }; if let Some(dispatch) = crate::object::handle_property_set_dispatch() { - unsafe { dispatch(handle, name.as_ptr(), name.len(), value) }; + unsafe { dispatch(handle, name.as_ptr(), name.len(), value.get_nanbox_f64()) }; } Some(true) } diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index 0697c30bf1..c703489592 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -602,12 +602,19 @@ fn set_timer_ref_state(id: i64, has_ref: bool) { } fn record_timer_handle_kind(id: i64, kind: CallbackTimerKind) { - TIMER_HANDLE_KINDS.lock().unwrap().insert(id, kind); + let mut kinds = TIMER_HANDLE_KINDS.lock().unwrap(); + if kinds.len() >= TIMER_REF_STATES_CAP && !kinds.contains_key(&id) { + if let Some(oldest) = kinds.keys().copied().min() { + kinds.remove(&oldest); + } + } + kinds.insert(id, kind); } /// Synthetic constructor object for `Timeout`/`Immediate` native handles. -/// Timer ids outlive queue removal, so the kind table intentionally retains -/// the entry after clear/fire just as Node retains the wrapper's prototype. +/// Timer ids outlive queue removal, so the kind table retains recent entries +/// after clear/fire just as Node retains the wrapper's prototype. The bounded +/// inventory avoids unbounded growth in long-running processes. pub(crate) fn timer_constructor_value(id: i64) -> Option { let kind = TIMER_HANDLE_KINDS.lock().unwrap().get(&id).copied()?; let name = match kind { diff --git a/crates/perry-stdlib/src/async_local_storage.rs b/crates/perry-stdlib/src/async_local_storage.rs index 56dc0c90cc..5fc45afd9e 100644 --- a/crates/perry-stdlib/src/async_local_storage.rs +++ b/crates/perry-stdlib/src/async_local_storage.rs @@ -41,8 +41,10 @@ unsafe fn validate_callback(callback: f64) -> *const ClosureHeader { } } let message = "callback is not a function"; + let scope = perry_runtime::gc::RuntimeHandleScope::new(); let msg = perry_runtime::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); - let err = perry_runtime::error::js_typeerror_new(msg); + let msg = scope.root_string_ptr(msg); + let err = msg.with_mut_ptr(|msg| perry_runtime::error::js_typeerror_new(msg)); perry_runtime::exception::js_throw(perry_runtime::value::js_nanbox_pointer(err as i64)) } @@ -85,8 +87,10 @@ impl AsyncLocalStorageHandle { fn throw_invalid_receiver() -> ! { let message = b"Value of \"this\" must be of type AsyncLocalStorage"; + let scope = perry_runtime::gc::RuntimeHandleScope::new(); let msg = perry_runtime::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); - let err = perry_runtime::error::js_typeerror_new(msg); + let msg = scope.root_string_ptr(msg); + let err = msg.with_mut_ptr(|msg| perry_runtime::error::js_typeerror_new(msg)); perry_runtime::exception::js_throw(perry_runtime::value::js_nanbox_pointer(err as i64)) } @@ -100,14 +104,17 @@ pub(crate) fn resolve_async_local_storage_handle(receiver: Handle) -> Option> 48 != 0x7FFD { return None; } @@ -123,21 +130,23 @@ pub extern "C" fn js_async_local_storage_subclass_init(this_value: f64) -> f64 { let scope = perry_runtime::gc::RuntimeHandleScope::new(); let this_handle = scope.root_nanbox_f64(this_value); let backing = js_async_local_storage_new(); - let current_this = this_handle.get_nanbox_f64(); - let raw = perry_runtime::value::js_nanbox_get_pointer(current_this) + let backing_value = scope.root_nanbox_f64(perry_runtime::value::js_nanbox_pointer(backing)); + let raw = perry_runtime::value::js_nanbox_get_pointer(this_handle.get_nanbox_f64()) as *mut perry_runtime::object::ObjectHeader; if !raw.is_null() && perry_runtime::value::addr_class::is_above_handle_band(raw as usize) && perry_runtime::value::addr_class::is_valid_obj_ptr(raw as *const u8) { - let key = perry_runtime::string::js_string_from_bytes( + let key = scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( SUBCLASS_BACKING_KEY.as_ptr(), SUBCLASS_BACKING_KEY.len() as u32, - ); + )); + let raw = perry_runtime::value::js_nanbox_get_pointer(this_handle.get_nanbox_f64()) + as *mut perry_runtime::object::ObjectHeader; perry_runtime::object::js_object_set_field_by_name( raw, - key, - perry_runtime::value::js_nanbox_pointer(backing), + key.get_raw_mut_ptr(), + backing_value.get_nanbox_f64(), ); for method in [ b"run".as_slice(), @@ -148,14 +157,16 @@ pub extern "C" fn js_async_local_storage_subclass_init(this_value: f64) -> f64 { ] { let value = crate::common::dispatch::unbound_async_local_storage_method(method); let value_handle = scope.root_nanbox_f64(value); - let key = - perry_runtime::string::js_string_from_bytes(method.as_ptr(), method.len() as u32); + let key = scope.root_string_ptr(perry_runtime::string::js_string_from_bytes( + method.as_ptr(), + method.len() as u32, + )); let current_raw = perry_runtime::value::js_nanbox_get_pointer(this_handle.get_nanbox_f64()) as *mut perry_runtime::object::ObjectHeader; perry_runtime::object::js_object_set_field_by_name( current_raw, - key, + key.get_raw_mut_ptr(), value_handle.get_nanbox_f64(), ); } @@ -181,17 +192,24 @@ pub unsafe extern "C" fn js_async_local_storage_run( callback: f64, args_array: i64, ) -> f64 { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(perry_runtime::value::js_nanbox_pointer(receiver)); + let store = scope.root_nanbox_f64(store); + let callback = scope.root_nanbox_f64(callback); + let args_array = scope.root_raw_const_ptr(args_array as *const ArrayHeader); // Validate before mutating the async context so an invalid callback throws // without leaving a pushed store behind (#3092). - let cb = validate_callback(callback); + let _ = validate_callback(callback.get_nanbox_f64()); + let receiver = perry_runtime::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()); let handle = resolve_async_local_storage_handle(receiver).unwrap_or_else(|| throw_invalid_receiver()); // A context guard mirrors the pop below: if the callback throws, // `js_throw` applies the guard while unwinding so the catch site still // observes the pre-`run` store (#788, Node restores via try/finally). - js_async_context_als_run_enter(handle, store); - let result = call_with_forwarded_args(cb, args_array); + js_async_context_als_run_enter(handle, store.get_nanbox_f64()); + let cb = validate_callback(callback.get_nanbox_f64()); + let result = call_with_forwarded_args(cb, args_array.get_raw_const_ptr::() as i64); js_async_context_als_scope_leave(); result @@ -210,8 +228,12 @@ pub extern "C" fn js_async_local_storage_get_store(receiver: Handle) -> f64 { /// Push store onto stack (caller is responsible for cleanup) #[no_mangle] pub extern "C" fn js_async_local_storage_enter_with(receiver: Handle, store: f64) { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(perry_runtime::value::js_nanbox_pointer(receiver)); + let store = scope.root_nanbox_f64(store); + let receiver = perry_runtime::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()); if let Some(handle) = resolve_async_local_storage_handle(receiver) { - unsafe { js_async_context_als_enter_with(handle, store) }; + unsafe { js_async_context_als_enter_with(handle, store.get_nanbox_f64()) }; } } @@ -225,14 +247,20 @@ pub unsafe extern "C" fn js_async_local_storage_exit( callback: f64, args_array: i64, ) -> f64 { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(perry_runtime::value::js_nanbox_pointer(receiver)); + let callback = scope.root_nanbox_f64(callback); + let args_array = scope.root_raw_const_ptr(args_array as *const ArrayHeader); // Validate before clearing the context so an invalid callback throws // without disturbing the saved store (#3092). - let cb = validate_callback(callback); + let _ = validate_callback(callback.get_nanbox_f64()); + let receiver = perry_runtime::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()); let handle = resolve_async_local_storage_handle(receiver).unwrap_or_else(|| throw_invalid_receiver()); js_async_context_als_exit_enter(handle); - let result = call_with_forwarded_args(cb, args_array); + let cb = validate_callback(callback.get_nanbox_f64()); + let result = call_with_forwarded_args(cb, args_array.get_raw_const_ptr::() as i64); js_async_context_als_scope_leave(); diff --git a/crates/perry-stdlib/src/common/dispatch/emitter_als.rs b/crates/perry-stdlib/src/common/dispatch/emitter_als.rs index 48605b8c3e..4a324364cd 100644 --- a/crates/perry-stdlib/src/common/dispatch/emitter_als.rs +++ b/crates/perry-stdlib/src/common/dispatch/emitter_als.rs @@ -55,7 +55,10 @@ extern "C" fn async_local_storage_unbound_method_thunk( let name_len = perry_runtime::closure::js_closure_get_capture_ptr(closure, 1) as usize; let name = std::slice::from_raw_parts(name_ptr as *const u8, name_len); let name_str = std::str::from_utf8(name).unwrap_or(""); - let receiver = perry_runtime::object::js_implicit_this_get(); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let rest = scope.root_nanbox_f64(rest); + let receiver_handle = scope.root_nanbox_f64(perry_runtime::object::js_implicit_this_get()); + let receiver = receiver_handle.get_nanbox_f64(); let receiver_raw = if receiver.to_bits() >> 48 == 0x7FFD { (receiver.to_bits() & POINTER_MASK_BITS) as i64 } else { @@ -71,8 +74,8 @@ extern "C" fn async_local_storage_unbound_method_thunk( return TAG_UNDEFINED_F64; } - let args_array = - perry_runtime::value::js_nanbox_get_pointer(rest) as *const perry_runtime::ArrayHeader; + let args_array = perry_runtime::value::js_nanbox_get_pointer(rest.get_nanbox_f64()) + as *const perry_runtime::ArrayHeader; let args = if args_array.is_null() { Vec::new() } else { @@ -88,6 +91,12 @@ extern "C" fn async_local_storage_unbound_method_thunk( if let Some(value) = dispatch_async_local_storage_method(receiver_raw, name_str, &args) { return value; } + let receiver = receiver_handle.get_nanbox_f64(); + let receiver_raw = if receiver.to_bits() >> 48 == 0x7FFD { + (receiver.to_bits() & POINTER_MASK_BITS) as i64 + } else { + 0 + }; // The remaining cases are invalid receivers. Brand-checked methods // throw; enterWith/disable already returned the deliberate no-op above. @@ -142,7 +151,10 @@ pub(crate) unsafe fn dispatch_async_local_storage_method( ) { return None; } + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let arg_handles = scope.root_nanbox_f64_slice(args); let handle = crate::async_local_storage::resolve_async_local_storage_handle(handle)?; + let args = perry_runtime::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); Some(match method { "getStore" => crate::async_local_storage::js_async_local_storage_get_store(handle), "run" if args.len() >= 2 => { @@ -152,6 +164,8 @@ pub(crate) unsafe fn dispatch_async_local_storage_method( } else { pack_args_array(rest) as i64 }; + let args = + perry_runtime::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); crate::async_local_storage::js_async_local_storage_run( handle, args[0], args[1], rest_array, ) @@ -168,6 +182,8 @@ pub(crate) unsafe fn dispatch_async_local_storage_method( } else { pack_args_array(rest) as i64 }; + let args = + perry_runtime::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); crate::async_local_storage::js_async_local_storage_exit(handle, args[0], rest_array) } "disable" => { diff --git a/crates/perry-stdlib/src/common/dispatch_http.rs b/crates/perry-stdlib/src/common/dispatch_http.rs index c72f963591..aae66413fd 100644 --- a/crates/perry-stdlib/src/common/dispatch_http.rs +++ b/crates/perry-stdlib/src/common/dispatch_http.rs @@ -163,6 +163,11 @@ pub(super) unsafe fn dispatch_client_incoming_method( event_ptr: *const perry_runtime::StringHeader, callback: i64, ) -> i64; + fn js_http_once( + handle: i64, + event_ptr: *const perry_runtime::StringHeader, + callback: i64, + ) -> i64; fn js_http_incoming_message_pipe(handle: i64, dest: f64) -> f64; } @@ -179,7 +184,7 @@ pub(super) unsafe fn dispatch_client_incoming_method( } self_ref } - "on" | "once" | "addListener" if args.len() >= 2 => { + "on" | "addListener" if args.len() >= 2 => { let event = (args[0].to_bits() & PTR_MASK) as *const perry_runtime::StringHeader; let callback = (args[1].to_bits() & PTR_MASK) as i64; unsafe { @@ -187,6 +192,14 @@ pub(super) unsafe fn dispatch_client_incoming_method( } self_ref } + "once" if args.len() >= 2 => { + let event = (args[0].to_bits() & PTR_MASK) as *const perry_runtime::StringHeader; + let callback = (args[1].to_bits() & PTR_MASK) as i64; + unsafe { + js_http_once(handle, event, callback); + } + self_ref + } // `res.pipe(dest)` — register the destination and return it (Node's // pipe-returns-destination contract; node-fetch reads the response // body via `res.pipe(new PassThrough())`). diff --git a/crates/perry-stdlib/src/tls/event_pump.rs b/crates/perry-stdlib/src/tls/event_pump.rs index 114cddbf5f..0d691ebabd 100644 --- a/crates/perry-stdlib/src/tls/event_pump.rs +++ b/crates/perry-stdlib/src/tls/event_pump.rs @@ -45,9 +45,15 @@ pub unsafe extern "C" fn js_tls_process_pending() -> i32 { .and_then(|per| per.remove("listening")) .unwrap_or_default() }; + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callbacks: Vec<_> = callbacks + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); for cb in callbacks { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call0(cb); } } drain_once_listeners(server_id, "listening"); @@ -55,9 +61,15 @@ pub unsafe extern "C" fn js_tls_process_pending() -> i32 { PendingTlsEvent::ServerSecureConnection(server_id, socket_id) => { let socket = nanbox_handle(socket_id); for event_name in ["secureConnection", "connection"] { - for cb in listeners_for(server_id, event_name) { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, socket); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callbacks: Vec<_> = listeners_for(server_id, event_name) + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); + for cb in callbacks { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call1(cb, socket); } } drain_once_listeners(server_id, event_name); @@ -70,9 +82,15 @@ pub unsafe extern "C" fn js_tls_process_pending() -> i32 { .and_then(|per| per.remove("close")) .unwrap_or_default() }; + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callbacks: Vec<_> = callbacks + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); for cb in callbacks { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call0(cb); } } servers().lock().unwrap().remove(&server_id); @@ -80,45 +98,76 @@ pub unsafe extern "C" fn js_tls_process_pending() -> i32 { once_flags().lock().unwrap().remove(&server_id); } PendingTlsEvent::ServerError(server_id, msg) => { - let err = build_error_object(&msg); - for cb in listeners_for(server_id, "error") { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, err); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let err = scope.root_nanbox_f64(build_error_object(&msg)); + let callbacks: Vec<_> = listeners_for(server_id, "error") + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); + for cb in callbacks { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call1(cb, err.get_nanbox_f64()); } } drain_once_listeners(server_id, "error"); } PendingTlsEvent::ServerTlsClientError(server_id, socket_id, msg, code) => { - let err = build_error_object_with_code(&msg, code.as_deref()); - let socket = nanbox_handle(socket_id); - for cb in listeners_for(server_id, "tlsClientError") { - if cb != 0 { - js_closure_call2(cb as *const ClosureHeader, err, socket); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let err = + scope.root_nanbox_f64(build_error_object_with_code(&msg, code.as_deref())); + let socket = scope.root_nanbox_f64(nanbox_handle(socket_id)); + let callbacks: Vec<_> = listeners_for(server_id, "tlsClientError") + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); + for cb in callbacks { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call2(cb, err.get_nanbox_f64(), socket.get_nanbox_f64()); } } drain_once_listeners(server_id, "tlsClientError"); } PendingTlsEvent::SocketData(socket_id, bytes) => { - let data = buffer_from_bytes(&bytes); - for cb in listeners_for(socket_id, "data") { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, data); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let data = scope.root_nanbox_f64(buffer_from_bytes(&bytes)); + let callbacks: Vec<_> = listeners_for(socket_id, "data") + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); + for cb in callbacks { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call1(cb, data.get_nanbox_f64()); } } drain_once_listeners(socket_id, "data"); } PendingTlsEvent::SocketEnd(socket_id) => { - for cb in listeners_for(socket_id, "end") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callbacks: Vec<_> = listeners_for(socket_id, "end") + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); + for cb in callbacks { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call0(cb); } } drain_once_listeners(socket_id, "end"); } PendingTlsEvent::SocketClose(socket_id) => { - for cb in listeners_for(socket_id, "close") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let callbacks: Vec<_> = listeners_for(socket_id, "close") + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); + for cb in callbacks { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call0(cb); } } sockets().lock().unwrap().remove(&socket_id); @@ -126,10 +175,16 @@ pub unsafe extern "C" fn js_tls_process_pending() -> i32 { once_flags().lock().unwrap().remove(&socket_id); } PendingTlsEvent::SocketError(socket_id, msg) => { - let err = build_error_object(&msg); - for cb in listeners_for(socket_id, "error") { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, err); + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let err = scope.root_nanbox_f64(build_error_object(&msg)); + let callbacks: Vec<_> = listeners_for(socket_id, "error") + .into_iter() + .map(|cb| scope.root_raw_const_ptr(cb as *const ClosureHeader)) + .collect(); + for cb in callbacks { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call1(cb, err.get_nanbox_f64()); } } drain_once_listeners(socket_id, "error"); diff --git a/crates/perry-stdlib/src/webcrypto/digest.rs b/crates/perry-stdlib/src/webcrypto/digest.rs index 235483ca7d..e56f1396de 100644 --- a/crates/perry-stdlib/src/webcrypto/digest.rs +++ b/crates/perry-stdlib/src/webcrypto/digest.rs @@ -52,14 +52,30 @@ pub unsafe extern "C" fn js_webcrypto_digest(algo_bits: f64, data_bits: f64) -> if buf.is_null() { return reject_with_dom_exception("OperationError", "The operation failed"); } - let value = f64::from_bits(JSValue::pointer(buf as *const u8).bits()); - let promise = perry_runtime::promise::js_promise_new(); - let promise_val = f64::from_bits(JSValue::pointer(promise as *const u8).bits()); + 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 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); - perry_runtime::closure::js_closure_set_capture_ptr(cl, 0, promise_val.to_bits() as i64); - perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value.to_bits() as i64); + let cl = scope.root_raw_mut_ptr(cl); + perry_runtime::closure::js_closure_set_capture_ptr( + cl.get_raw_mut_ptr(), + 0, + promise_val.to_bits() as i64, + ); + perry_runtime::closure::js_closure_set_capture_ptr( + cl.get_raw_mut_ptr(), + 1, + value.get_nanbox_f64().to_bits() as i64, + ); // Remaining macrotask hops (Node's threadpool digest = 2 setImmediate ticks). - perry_runtime::closure::js_closure_set_capture_ptr(cl, 2, 2); - perry_runtime::timer::schedule_native_callback(cl as i64, &[], "HASHREQUEST"); - promise + perry_runtime::closure::js_closure_set_capture_ptr(cl.get_raw_mut_ptr(), 2, 2); + perry_runtime::timer::schedule_native_callback( + cl.get_raw_mut_ptr::() as i64, + &[], + "HASHREQUEST", + ); + promise.get_raw_mut_ptr() } diff --git a/crates/perry-stdlib/src/webcrypto/hmac.rs b/crates/perry-stdlib/src/webcrypto/hmac.rs index ac7b6fc395..762efecdcc 100644 --- a/crates/perry-stdlib/src/webcrypto/hmac.rs +++ b/crates/perry-stdlib/src/webcrypto/hmac.rs @@ -271,7 +271,7 @@ pub unsafe extern "C" fn js_webcrypto_verify( Err((name, message)) => return reject_with_dom_exception(name, message), }; if output_length == 0 { - return resolve_with_bool(false); + return resolve_with_bool_provider(false, "SIGNREQUEST"); } let customization = object_field_bytes(algo_bits.to_bits(), b"customization").unwrap_or_else(Vec::new); @@ -308,7 +308,7 @@ pub unsafe extern "C" fn js_webcrypto_verify( }; let sig = match P256EcdsaSignature::from_slice(&provided_sig) { Ok(s) => s, - Err(_) => return resolve_with_bool(false), + Err(_) => return resolve_with_bool_provider(false, "SIGNREQUEST"), }; verifying_key.verify(&data_bytes, &sig).is_ok() } @@ -321,7 +321,7 @@ pub unsafe extern "C" fn js_webcrypto_verify( }; let sig = match P384EcdsaSignature::from_slice(&provided_sig) { Ok(s) => s, - Err(_) => return resolve_with_bool(false), + Err(_) => return resolve_with_bool_provider(false, "SIGNREQUEST"), }; verifying_key.verify(&data_bytes, &sig).is_ok() } @@ -334,7 +334,7 @@ pub unsafe extern "C" fn js_webcrypto_verify( }; let sig = match P521EcdsaSignature::from_slice(&provided_sig) { Ok(s) => s, - Err(_) => return resolve_with_bool(false), + Err(_) => return resolve_with_bool_provider(false, "SIGNREQUEST"), }; verifying_key.verify(&data_bytes, &sig).is_ok() } @@ -362,7 +362,7 @@ pub unsafe extern "C" fn js_webcrypto_verify( }; let signature = match ed25519_dalek::Signature::try_from(provided_sig.as_slice()) { Ok(sig) => sig, - Err(_) => return resolve_with_bool(false), + Err(_) => return resolve_with_bool_provider(false, "SIGNREQUEST"), }; use ed25519_dalek::Verifier as _; verifying_key.verify(&data_bytes, &signature).is_ok() @@ -388,7 +388,7 @@ pub unsafe extern "C" fn js_webcrypto_verify( }; let signature = match ed448_goldilocks::Signature::from_slice(&provided_sig) { Ok(sig) => sig, - Err(_) => return resolve_with_bool(false), + Err(_) => return resolve_with_bool_provider(false, "SIGNREQUEST"), }; verifying_key.verify_raw(&signature, &data_bytes).is_ok() } else if algo_upper == "RSASSA-PKCS1-V1_5" { diff --git a/crates/perry-stdlib/src/webcrypto/util.rs b/crates/perry-stdlib/src/webcrypto/util.rs index 258a90bebb..f647b7b5e2 100644 --- a/crates/perry-stdlib/src/webcrypto/util.rs +++ b/crates/perry-stdlib/src/webcrypto/util.rs @@ -962,11 +962,6 @@ pub(super) unsafe fn resolve_with_bytes_provider( resolve_with_bits_provider(val, provider_type) } -pub(super) unsafe fn resolve_with_bool(b: bool) -> *mut Promise { - let bits = if b { TAG_TRUE } else { TAG_FALSE }; - resolve_with_bits(bits) -} - pub(super) unsafe fn resolve_with_bool_provider( b: bool, provider_type: &'static str, diff --git a/crates/perry-stdlib/src/worker_threads/worker_pump.rs b/crates/perry-stdlib/src/worker_threads/worker_pump.rs index 814b3407a2..6e05f1d72e 100644 --- a/crates/perry-stdlib/src/worker_threads/worker_pump.rs +++ b/crates/perry-stdlib/src/worker_threads/worker_pump.rs @@ -237,10 +237,6 @@ fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { (worker.object_bits, callbacks, worker.async_resources) }; - for resource in async_resources { - perry_runtime::async_hooks::enter_resource_scope(resource); - } - // Web-style `addEventListener` listeners receive a `MessageEvent` wrapper // (with `.data`) for "message" events; Node-style `on` listeners receive the // raw payload. Lazily build the event object only if a web listener exists. @@ -260,6 +256,12 @@ fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { }) .collect::>(); let arg_handle = arg.map(|a| scope.root_nanbox_f64(a)); + let resource = match event { + "online" => async_resources[1], + "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"), @@ -305,7 +307,5 @@ fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { perry_runtime::closure::js_closure_call0(closure); } } - for resource in async_resources.into_iter().rev() { - perry_runtime::async_hooks::leave_resource_scope(resource.async_id); - } + perry_runtime::async_hooks::leave_resource_scope(resource.async_id); } diff --git a/crates/perry-stdlib/src/zlib.rs b/crates/perry-stdlib/src/zlib.rs index 415b037903..0b4bd1902e 100644 --- a/crates/perry-stdlib/src/zlib.rs +++ b/crates/perry-stdlib/src/zlib.rs @@ -1354,6 +1354,7 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { 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); @@ -1395,6 +1396,7 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { } 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 { @@ -1402,6 +1404,9 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { } } 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 { @@ -1433,6 +1438,7 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { } } 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); @@ -1443,11 +1449,15 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { } 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); } + if let Some(async_id) = destroy_after_dispatch { + perry_runtime::async_hooks::defer_destroy_after_check_turns(async_id, 4); + } } count } diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index ffa282300a..776e0e5c6a 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -51,7 +51,6 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_ROOT_SPILL_RELOCATIONS", // #8583: selects the descriptor-backed lowering for large constant arrays. // The two paths emit different IR and therefore require distinct cache keys. - "PERRY_CONST_ARRAY_DESCRIPTOR", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_INLINE_SHADOW_SLOT", "PERRY_DISABLE_BUFFER_FAST_PATH", @@ -143,7 +142,6 @@ const BUILD_CACHE_ENV_EXCLUSIONS: &[&str] = &[ "PERRY_REPSEL_DEBUG", "PERRY_STATEPOINT_REPORT", // Writes malformed dialect IR for diagnostics without changing emitted code. - "PERRY_DIALECT_DUMP", // `opt_report`'s own module doc states the contract this exclusion rests // on: "Observational only. Nothing in this module is read by codegen … // the returned fact sets are bit-identical with the report on and off, diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index 9036459752..e2cab550c5 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -955 +950 diff --git a/scripts/thread_local_cold_allowlist.json b/scripts/thread_local_cold_allowlist.json index 566ab8a28c..db158dedeb 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": 259, + "_hot_declarations": 263, "files": { "crates/perry-runtime/src/agent.rs": 1, "crates/perry-runtime/src/arena/block.rs": 2, From 5071f721f1a2e72e42bb32d0c971cdc7008605b5 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:30:48 +0200 Subject: [PATCH 02/13] fix(doctor): reject stale runtime archives --- Cargo.lock | 1 + crates/perry-runtime/Cargo.toml | 3 + crates/perry-runtime/build.rs | 163 ++++++++++ crates/perry-runtime/src/build_stamp.rs | 30 ++ crates/perry-runtime/src/lib.rs | 2 + crates/perry/src/commands/compile.rs | 5 + .../src/commands/compile/library_search.rs | 2 +- .../src/commands/compile/run_pipeline.rs | 17 +- .../src/commands/compile/runtime_compat.rs | 278 ++++++++++++++++++ crates/perry/src/commands/doctor.rs | 24 +- 10 files changed, 515 insertions(+), 10 deletions(-) create mode 100644 crates/perry-runtime/src/build_stamp.rs create mode 100644 crates/perry/src/commands/compile/runtime_compat.rs diff --git a/Cargo.lock b/Cargo.lock index 6389c5f135..42854ff5f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6332,6 +6332,7 @@ dependencies = [ "ryu", "serde", "serde_json", + "sha2 0.11.0", "socket2", "taffy", "temporal_rs", diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index f02ee16750..f948f9752f 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -391,6 +391,9 @@ mach2 = "0.6" # See `build.rs` and issue #395 for the rationale. [build-dependencies] perry-dispatch = { path = "../perry-dispatch" } +# Build-time only: fingerprints the compiler/runtime source contract embedded +# in libperry_runtime so the CLI can reject a stale archive before linking. +sha2 = "0.11" # Build-time only (does NOT ship in the runtime binary): generates the WHATWG # single-byte TextDecoder index tables so they are always spec-accurate. Only # the generated `[u16; 128]` arrays land in the binary. Already vetted in the diff --git a/crates/perry-runtime/build.rs b/crates/perry-runtime/build.rs index f33d16410c..248044b192 100644 --- a/crates/perry-runtime/build.rs +++ b/crates/perry-runtime/build.rs @@ -48,8 +48,170 @@ //! line — see `src/stub_diag.rs` for the env-var policy. use perry_dispatch::{ArgKind, MethodRow, ReturnKind}; +use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::fmt::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Source trees that define the compiler <-> runtime contract. A clean git +/// checkout uses the commit as its build id; dirty/source-only builds hash +/// these inputs so rebuilding the compiler without rebuilding the archive is +/// still detected even though the package version did not change. +const RUNTIME_BUILD_INPUTS: &[&str] = &[ + "Cargo.toml", + "Cargo.lock", + "crates/perry/Cargo.toml", + "crates/perry/src", + "crates/perry-codegen/Cargo.toml", + "crates/perry-codegen/src", + "crates/perry-hir/Cargo.toml", + "crates/perry-hir/src", + "crates/perry-transform/Cargo.toml", + "crates/perry-transform/src", + "crates/perry-runtime/Cargo.toml", + "crates/perry-runtime/build.rs", + "crates/perry-runtime/src", +]; + +/// `cargo package` builds the crate from an isolated directory without the +/// workspace siblings above. Hash the packaged runtime itself in that layout; +/// the compiler and static wrapper both consume this same crate artifact. +const PACKAGED_RUNTIME_BUILD_INPUTS: &[&str] = &["Cargo.toml", "build.rs", "src"]; + +fn command_stdout(root: &Path, args: &[&str]) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8(output.stdout).ok()?.trim().to_string()) +} + +fn sanitize_build_id(value: &str) -> String { + value + .chars() + .take(128) + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':') { + c + } else { + '_' + } + }) + .collect() +} + +fn collect_source_files(root: &Path, path: &Path, out: &mut Vec) { + let Ok(metadata) = std::fs::metadata(path) else { + return; + }; + if metadata.is_file() { + out.push(path.to_path_buf()); + return; + } + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let child = entry.path(); + if child.strip_prefix(root).ok().is_some_and(|relative| { + relative + .components() + .any(|part| part.as_os_str() == "target" || part.as_os_str() == ".git") + }) { + continue; + } + collect_source_files(root, &child, out); + } +} + +fn source_build_id(root: &Path, inputs: &[&str]) -> String { + let mut files = Vec::new(); + for relative in inputs { + collect_source_files(root, &root.join(relative), &mut files); + } + files.sort(); + files.dedup(); + + let mut hasher = Sha256::new(); + hasher.update(b"perry-runtime-build-inputs-v1\0"); + for path in files { + println!("cargo:rerun-if-changed={}", path.display()); + let relative = path.strip_prefix(root).unwrap_or(&path); + hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update(b"\0"); + match std::fs::read(&path) { + Ok(bytes) => { + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + Err(_) => hasher.update(b"unreadable"), + } + hasher.update(b"\0"); + } + let mut hex = String::with_capacity(64); + for byte in hasher.finalize() { + write!(hex, "{byte:02x}").expect("write source fingerprint"); + } + format!("src:{hex}") +} + +fn emit_runtime_build_id() { + println!("cargo:rerun-if-env-changed=PERRY_BUILD_COMMIT"); + let manifest_dir = + PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set")); + let workspace_candidate = manifest_dir.join("../.."); + let workspace_layout = workspace_candidate + .join("crates/perry-runtime/Cargo.toml") + .is_file(); + let (root, inputs) = if workspace_layout { + (workspace_candidate, RUNTIME_BUILD_INPUTS) + } else { + (manifest_dir, PACKAGED_RUNTIME_BUILD_INPUTS) + }; + let root = root.canonicalize().unwrap_or(root); + + // Make branch/commit changes rerun this build script even when the source + // files themselves are byte-identical (for example after a rebase). + if workspace_layout { + if let Some(git_head) = command_stdout(&root, &["rev-parse", "--git-path", "HEAD"]) { + println!("cargo:rerun-if-changed={git_head}"); + } + if let Some(symbolic_ref) = command_stdout(&root, &["symbolic-ref", "-q", "HEAD"]) { + if let Some(git_ref) = + command_stdout(&root, &["rev-parse", "--git-path", &symbolic_ref]) + { + println!("cargo:rerun-if-changed={git_ref}"); + } + } + } + + let explicit = std::env::var("PERRY_BUILD_COMMIT") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| format!("git:{}", sanitize_build_id(value.trim()))); + + let source_id = source_build_id(&root, inputs); + let clean_commit = workspace_layout + .then(|| command_stdout(&root, &["rev-parse", "--verify", "HEAD"])) + .flatten() + .filter(|_| { + let mut args = vec!["status", "--porcelain", "--untracked-files=normal", "--"]; + args.extend_from_slice(inputs); + command_stdout(&root, &args).is_some_and(|status| status.is_empty()) + }) + .map(|commit| format!("git:{}", sanitize_build_id(&commit))); + + let build_id = explicit.or(clean_commit).unwrap_or(source_id); + println!("cargo:rustc-env=PERRY_RUNTIME_BUILD_ID={build_id}"); +} fn arg_kind_rust_type(k: ArgKind) -> &'static str { match k { @@ -372,6 +534,7 @@ fn generate_single_byte_encodings(out_dir: &str) { fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=../perry-dispatch/src/lib.rs"); + emit_runtime_build_id(); let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); generate_single_byte_encodings(&out_dir); diff --git a/crates/perry-runtime/src/build_stamp.rs b/crates/perry-runtime/src/build_stamp.rs new file mode 100644 index 0000000000..2bcc4e5af2 --- /dev/null +++ b/crates/perry-runtime/src/build_stamp.rs @@ -0,0 +1,30 @@ +//! Build identity embedded in every `libperry_runtime` archive. +//! +//! The compiler reads this marker before linking. Keeping it in the runtime +//! crate (rather than a packaging sidecar) means copied archives, Cargo-built +//! archives, compressed npm archives, and platform-suffixed archives all carry +//! their identity with them. + +/// Revision/fingerprint produced by `build.rs` from the compiler/runtime +/// contract sources. Clean checkouts use `git:`; dirty or source-only +/// builds use `src:`. +pub const PERRY_RUNTIME_BUILD_ID: &str = env!("PERRY_RUNTIME_BUILD_ID"); + +/// NUL-terminated record deliberately stored as plain ASCII so the CLI can +/// find it by streaming over either an ar archive (`.a`) or a COFF library +/// (`.lib`) without invoking platform-specific archive tools. +pub const PERRY_RUNTIME_BUILD_STAMP: &str = concat!( + "PERRY_RUNTIME_BUILD_STAMP_V1|version=", + env!("CARGO_PKG_VERSION"), + "|build=", + env!("PERRY_RUNTIME_BUILD_ID"), + "\0", +); + +// `#[used]` keeps both this reference and its string data in the rlib object +// set copied by perry-runtime-static into libperry_runtime. The symbol stays +// mangled so linking a stdlib archive that also contains perry-runtime cannot +// create a duplicate public C symbol. +#[used] +#[doc(hidden)] +pub static PERRY_RUNTIME_BUILD_STAMP_EMBEDDED: &[u8] = PERRY_RUNTIME_BUILD_STAMP.as_bytes(); diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index c88131b72f..202d0f25b2 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -51,6 +51,8 @@ pub mod atomics_futex; pub mod bigint; pub mod r#box; pub mod buffer; +mod build_stamp; +pub use build_stamp::{PERRY_RUNTIME_BUILD_ID, PERRY_RUNTIME_BUILD_STAMP}; pub mod builtins; pub mod bun_compat; pub mod bun_ffi; diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 9eeff0f508..3838f24e58 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -53,6 +53,7 @@ mod windows_target; // reuses the subpath-imports + tsconfig-paths resolvers for `#` specifiers. pub(crate) mod resolve; mod resources; +mod runtime_compat; mod sandbox_buildrs; mod shared_tokio; mod strip_dedup; @@ -112,6 +113,10 @@ use resolve::{ is_recognized_text_asset, parse_native_library_manifest, parse_package_specifier, resolve_import, }; +pub(crate) use runtime_compat::{ + ensure_runtime_library_compatible, runtime_library_diagnostic, runtime_library_status, + RuntimeLibraryStatus, +}; use size_report::emit_size_report; use strip_dedup::{ dedup_native_lib_for_tier3, dedup_runtime_for_tier3, dedup_stdlib_for_tier3, diff --git a/crates/perry/src/commands/compile/library_search.rs b/crates/perry/src/commands/compile/library_search.rs index b0a242c857..7c8a1517ed 100644 --- a/crates/perry/src/commands/compile/library_search.rs +++ b/crates/perry/src/commands/compile/library_search.rs @@ -1196,7 +1196,7 @@ pub(super) fn find_runtime_library(target: Option<&str>) -> Result { "Could not find {lib}{extra}.\n\ Searched:\n{list}\n\n\ Fixes:\n\ - - From the perry workspace: cargo build --release -p perry-runtime{tf}\n\ + - From the perry workspace: cargo build --release -p perry-runtime-static{tf}\n\ - Out-of-tree install: set PERRY_RUNTIME_DIR to the directory containing {lib}\n\ (e.g. export PERRY_RUNTIME_DIR=/path/to/perry/target/release)", lib = lib_name, diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 636f22284a..2d26c29371 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -6210,6 +6210,13 @@ pub fn run_with_parse_cache( // emits `perry_module_init` instead of `main` (see is_dylib branch in // codegen/entry.rs, which now also covers `staticlib`). if is_staticlib { + let runtime_lib_for_manifest = optimized_libs + .runtime + .clone() + .or_else(|| find_runtime_library(target.as_deref()).ok()); + if let Some(runtime) = &runtime_lib_for_manifest { + ensure_runtime_library_compatible(runtime)?; + } let windows_target = is_windows_target(target.as_deref()); // Best-effort: drop a stale archive first so `ar` doesn't append to a // previous build's contents. @@ -6283,10 +6290,6 @@ pub fn run_with_parse_cache( "path": abs.display().to_string(), })); }; - let runtime_lib_for_manifest = optimized_libs - .runtime - .clone() - .or_else(|| find_runtime_library(target.as_deref()).ok()); if let Some(p) = &runtime_lib_for_manifest { push_archive(&mut link_archives, "runtime", p); } @@ -6596,6 +6599,12 @@ pub fn run_with_parse_cache( } else { find_runtime_library(target.as_deref())? }; + // #8752: discovery only proves that an archive exists. A runtime copied + // from an older compiler build can be found successfully and then fail at + // the final link with undefined symbols for newly emitted entrypoints. + // Read its embedded build stamp now so the error names the stale archive + // and both builds before invoking the platform linker. + ensure_runtime_library_compatible(&runtime_lib)?; // #1383 — under --enable-geisterhand, prefer the geisterhand-built stdlib // over the auto-optimized one. `build_geisterhand_libs` (already run above // when selecting `runtime_lib`) compiles perry-stdlib into target/geisterhand diff --git a/crates/perry/src/commands/compile/runtime_compat.rs b/crates/perry/src/commands/compile/runtime_compat.rs new file mode 100644 index 0000000000..b9c60df469 --- /dev/null +++ b/crates/perry/src/commands/compile/runtime_compat.rs @@ -0,0 +1,278 @@ +//! Compiler/runtime archive compatibility stamping (#8752). +//! +//! A stale `libperry_runtime` used to pass discovery and fail much later with +//! undefined symbols. The runtime now embeds a small version/build record; +//! this module streams the archive to find it and rejects skew before linking. + +use anyhow::{bail, Result}; +use std::fmt; +use std::fs::File; +use std::io::{self, BufReader, Read}; +use std::path::Path; + +const STAMP_MAGIC: &str = "PERRY_RUNTIME_BUILD_STAMP_V1"; +const STAMP_PREFIX: &[u8] = b"PERRY_RUNTIME_BUILD_STAMP_V1|"; +const MAX_STAMP_LEN: usize = 512; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RuntimeBuildStamp { + version: String, + build_id: String, +} + +impl RuntimeBuildStamp { + fn current() -> Self { + Self { + version: env!("CARGO_PKG_VERSION").to_string(), + build_id: perry_runtime::PERRY_RUNTIME_BUILD_ID.to_string(), + } + } + + fn parse(bytes: &[u8]) -> std::result::Result { + let text = + std::str::from_utf8(bytes).map_err(|error| format!("stamp is not UTF-8: {error}"))?; + let mut fields = text.split('|'); + if fields.next() != Some(STAMP_MAGIC) { + return Err("unexpected stamp format".to_string()); + } + let version = fields + .next() + .and_then(|field| field.strip_prefix("version=")) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "stamp has no version".to_string())?; + let build_id = fields + .next() + .and_then(|field| field.strip_prefix("build=")) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "stamp has no build id".to_string())?; + if fields.next().is_some() { + return Err("stamp has unexpected fields".to_string()); + } + Ok(Self { + version: version.to_string(), + build_id: build_id.to_string(), + }) + } + + fn short_build_id(&self) -> String { + let (kind, value) = self + .build_id + .split_once(':') + .unwrap_or(("build", self.build_id.as_str())); + let short: String = value.chars().take(12).collect(); + match kind { + "git" => format!("commit {short}"), + "src" => format!("source {short}"), + _ => format!("build {short}"), + } + } +} + +impl fmt::Display for RuntimeBuildStamp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "v{} ({})", self.version, self.short_build_id()) + } +} + +#[derive(Debug)] +pub(crate) enum RuntimeLibraryStatus { + Compatible(RuntimeBuildStamp), + MissingStamp, + MalformedStamp(String), + Mismatch { + expected: RuntimeBuildStamp, + found: RuntimeBuildStamp, + }, + Unreadable(io::Error), +} + +enum ScannedStamp { + Missing, + Bytes(Vec), + Unterminated, +} + +/// Stream instead of reading the whole archive into memory. Release runtime +/// archives can be tens of megabytes, while the record is at most 512 bytes. +fn scan_stamp(path: &Path) -> io::Result { + let mut reader = BufReader::new(File::open(path)?); + let mut buffer = [0_u8; 64 * 1024]; + let mut prefix_match = 0_usize; + let mut record: Option> = None; + + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + return Ok(match record { + Some(_) => ScannedStamp::Unterminated, + None => ScannedStamp::Missing, + }); + } + for &byte in &buffer[..read] { + if let Some(bytes) = record.as_mut() { + if byte == 0 { + return Ok(ScannedStamp::Bytes(std::mem::take(bytes))); + } + if bytes.len() >= MAX_STAMP_LEN { + return Ok(ScannedStamp::Unterminated); + } + bytes.push(byte); + continue; + } + + if byte == STAMP_PREFIX[prefix_match] { + prefix_match += 1; + if prefix_match == STAMP_PREFIX.len() { + record = Some(STAMP_PREFIX.to_vec()); + prefix_match = 0; + } + } else { + // The marker has no multi-byte self-overlap; preserving a + // leading `P` is enough to handle a mismatch at a new prefix. + prefix_match = usize::from(byte == STAMP_PREFIX[0]); + } + } + } +} + +pub(crate) fn runtime_library_status(path: &Path) -> RuntimeLibraryStatus { + let found = match scan_stamp(path) { + Ok(ScannedStamp::Missing) => return RuntimeLibraryStatus::MissingStamp, + Ok(ScannedStamp::Unterminated) => { + return RuntimeLibraryStatus::MalformedStamp( + "embedded stamp is unterminated or too long".to_string(), + ) + } + Ok(ScannedStamp::Bytes(bytes)) => match RuntimeBuildStamp::parse(&bytes) { + Ok(stamp) => stamp, + Err(error) => return RuntimeLibraryStatus::MalformedStamp(error), + }, + Err(error) => return RuntimeLibraryStatus::Unreadable(error), + }; + let expected = RuntimeBuildStamp::current(); + if found == expected { + RuntimeLibraryStatus::Compatible(found) + } else { + RuntimeLibraryStatus::Mismatch { expected, found } + } +} + +pub(crate) fn runtime_library_diagnostic(path: &Path, status: &RuntimeLibraryStatus) -> String { + let expected = RuntimeBuildStamp::current(); + let reason = match status { + RuntimeLibraryStatus::Compatible(found) => { + return format!("{} ({found}, matches this Perry)", path.display()) + } + RuntimeLibraryStatus::MissingStamp => format!( + "library build: unknown ({} has no build stamp and predates compatibility checks)\n Perry build: {expected}", + path.display() + ), + RuntimeLibraryStatus::MalformedStamp(error) => format!( + "library build: unknown (invalid stamp in {}: {error})\n Perry build: {expected}", + path.display() + ), + RuntimeLibraryStatus::Mismatch { expected, found } => format!( + "library build: {found}\n Perry build: {expected}\n library: {}", + path.display() + ), + RuntimeLibraryStatus::Unreadable(error) => format!( + "could not inspect {}: {error}\n Perry build: {expected}", + path.display() + ), + }; + + format!( + "runtime library does not match this Perry compiler:\n {reason}\n\ + The archive may be stale. Rebuild it with \ + `cargo build --release -p perry-runtime-static`, then replace {}, \ + or reinstall Perry so the binary and libraries come from the same package.", + path.display() + ) +} + +pub(crate) fn ensure_runtime_library_compatible(path: &Path) -> Result<()> { + let status = runtime_library_status(path); + if matches!(&status, RuntimeLibraryStatus::Compatible(_)) { + return Ok(()); + } + bail!(runtime_library_diagnostic(path, &status)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_archive(bytes: &[u8]) -> tempfile::NamedTempFile { + let mut file = tempfile::NamedTempFile::new().expect("create archive fixture"); + file.write_all(bytes).expect("write archive fixture"); + file + } + + fn encoded(stamp: &RuntimeBuildStamp) -> Vec { + format!( + "noise{STAMP_MAGIC}|version={}|build={}\0trailer", + stamp.version, stamp.build_id + ) + .into_bytes() + } + + #[test] + fn accepts_matching_embedded_stamp() { + let expected = RuntimeBuildStamp::current(); + let archive = write_archive(&encoded(&expected)); + assert!(matches!( + runtime_library_status(archive.path()), + RuntimeLibraryStatus::Compatible(found) if found == expected + )); + } + + #[test] + fn finds_stamp_across_reader_chunk_boundary() { + let expected = RuntimeBuildStamp::current(); + let mut bytes = vec![b'x'; 64 * 1024 - 7]; + bytes.extend(encoded(&expected)); + let archive = write_archive(&bytes); + assert!(matches!( + runtime_library_status(archive.path()), + RuntimeLibraryStatus::Compatible(_) + )); + } + + #[test] + fn rejects_unstamped_legacy_archive_with_refresh_help() { + let archive = write_archive(b"!\nlegacy runtime contents"); + let status = runtime_library_status(archive.path()); + assert!(matches!(&status, RuntimeLibraryStatus::MissingStamp)); + let diagnostic = runtime_library_diagnostic(archive.path(), &status); + assert!(diagnostic.contains("has no build stamp")); + assert!(diagnostic.contains("perry-runtime-static")); + assert!(diagnostic.contains(&archive.path().display().to_string())); + } + + #[test] + fn rejects_mismatched_archive_and_names_both_builds() { + let expected = RuntimeBuildStamp::current(); + let stale = RuntimeBuildStamp { + version: "0.0.1".to_string(), + build_id: "git:1111111111111111111111111111111111111111".to_string(), + }; + let archive = write_archive(&encoded(&stale)); + let status = runtime_library_status(archive.path()); + assert!(matches!(&status, RuntimeLibraryStatus::Mismatch { .. })); + let diagnostic = runtime_library_diagnostic(archive.path(), &status); + assert!(diagnostic.contains(&stale.to_string())); + assert!(diagnostic.contains(&expected.to_string())); + assert!(diagnostic.contains("archive may be stale")); + } + + #[test] + fn rejects_unterminated_stamp() { + let archive = + write_archive(format!("{STAMP_MAGIC}|version=1.0.0|build=git:abc").as_bytes()); + assert!(matches!( + runtime_library_status(archive.path()), + RuntimeLibraryStatus::MalformedStamp(_) + )); + } +} diff --git a/crates/perry/src/commands/doctor.rs b/crates/perry/src/commands/doctor.rs index 4b8d99c3fc..fa178912c9 100644 --- a/crates/perry/src/commands/doctor.rs +++ b/crates/perry/src/commands/doctor.rs @@ -272,16 +272,30 @@ fn check_runtime_library() -> CheckResult { "libperry_runtime.a" }; if let Some(path) = crate::commands::compile::find_library(lib_name, None) { - return CheckResult { - name: "runtime library".to_string(), - status: CheckStatus::Ok, - details: Some(path.display().to_string()), + let library_status = crate::commands::compile::runtime_library_status(&path); + return match &library_status { + crate::commands::compile::RuntimeLibraryStatus::Compatible(_) => CheckResult { + name: "runtime library".to_string(), + status: CheckStatus::Ok, + details: Some(crate::commands::compile::runtime_library_diagnostic( + &path, + &library_status, + )), + }, + _ => CheckResult { + name: "runtime library".to_string(), + status: CheckStatus::Error, + details: Some(crate::commands::compile::runtime_library_diagnostic( + &path, + &library_status, + )), + }, }; } CheckResult { name: "runtime library".to_string(), status: CheckStatus::Warning, - details: Some("not found - run: cargo build --release -p perry-runtime".to_string()), + details: Some("not found - run: cargo build --release -p perry-runtime-static".to_string()), } } From 3c1763da15f85f6fd830ed6fa691497ab90f151d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:37:52 +0200 Subject: [PATCH 03/13] docs: add runtime compatibility changelog fragment --- changelog.d/8816-runtime-library-build-stamp.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/8816-runtime-library-build-stamp.md diff --git a/changelog.d/8816-runtime-library-build-stamp.md b/changelog.d/8816-runtime-library-build-stamp.md new file mode 100644 index 0000000000..4946d68fab --- /dev/null +++ b/changelog.d/8816-runtime-library-build-stamp.md @@ -0,0 +1,4 @@ +Fixed stale or mismatched `libperry_runtime` archives passing `perry doctor` and +then failing during native linking with undefined runtime symbols. Runtime +archives now carry a compiler build identity that `perry doctor` and compile +pipelines verify before linking, with actionable rebuild and reinstall guidance. From 00bd1de659fc2121c422ea9484d1de152b455f9c Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:39:06 +0200 Subject: [PATCH 04/13] fix(sharp): support create input descriptors --- .../src/lower_call/native_table/media.rs | 7 +- crates/perry-ext-sharp/src/lib.rs | 248 +++++++++++++++--- .../perry-ext-sharp/src/test_async_shims.rs | 90 ++++++- crates/perry/tests/issue_8748_sharp_create.rs | 103 ++++++++ docs/src/stdlib/other.md | 11 + 5 files changed, 412 insertions(+), 47 deletions(-) create mode 100644 crates/perry/tests/issue_8748_sharp_create.rs diff --git a/crates/perry-codegen/src/lower_call/native_table/media.rs b/crates/perry-codegen/src/lower_call/native_table/media.rs index 0364f36ba3..44e1f12310 100644 --- a/crates/perry-codegen/src/lower_call/native_table/media.rs +++ b/crates/perry-codegen/src/lower_call/native_table/media.rs @@ -4,9 +4,10 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ // ========== sharp ========== // Factory: sharp(path) → js_sharp_from_file. Instance methods take // Handle (i64), compatible with the has_receiver:true dispatch path. - // `sharp(input)` accepts a file-path string OR a Buffer/Uint8Array of - // encoded image bytes. Pass the raw NaN-boxed value (NA_JSV) so - // `js_sharp_from_input` can branch on the Buffer registry probe. + // `sharp(input)` accepts a file-path string, a Buffer/Uint8Array of encoded + // image bytes, or a `{ create: { ... } }` descriptor. Pass the raw + // NaN-boxed value (NA_JSV) so `js_sharp_from_input` can branch on its + // representation. NativeModSig { module: "sharp", has_receiver: false, diff --git a/crates/perry-ext-sharp/src/lib.rs b/crates/perry-ext-sharp/src/lib.rs index 6192ce1736..480b8d8388 100644 --- a/crates/perry-ext-sharp/src/lib.rs +++ b/crates/perry-ext-sharp/src/lib.rs @@ -9,7 +9,7 @@ use perry_ffi::{ alloc_buffer, alloc_string, build_object_shape, get_handle, js_array_get, js_array_length, js_object_alloc_with_shape, js_object_set_field, read_buffer_bytes, read_bytes, read_string, register_handle, spawn_blocking, ArrayHeader, BufferHeader, Handle, JsPromise, JsString, - JsValue, ObjectHeader, Promise, StringHeader, + JsValue, Promise, StringHeader, TransientRootScope, }; use std::io::Cursor; @@ -23,7 +23,7 @@ mod test_async_shims; extern "C" { fn js_get_string_pointer_unified(value: f64) -> i64; fn js_buffer_is_buffer(ptr: i64) -> i32; - fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64; + fn js_object_get_field_by_name_boxed(receiver: f64, key: *const StringHeader) -> f64; fn js_string_from_bytes(data: *const u8, len: u32) -> *mut StringHeader; } @@ -31,16 +31,11 @@ extern "C" { /// `None` if `opts` isn't an object or the field isn't a number. Handles both /// int32- and f64-boxed numbers. unsafe fn opts_number_field(opts: f64, name: &str) -> Option { - let jv = JsValue::from_bits(opts.to_bits()); - if !jv.is_pointer() { - return None; - } - let obj = jv.as_pointer::(); - if obj.is_null() { - return None; - } + let scope = TransientRootScope::enter(); + let rooted_opts = scope.root_nanbox(opts); let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - let field = JsValue::from_bits(js_object_get_field_by_name_f64(obj, key).to_bits()); + let field = + JsValue::from_bits(js_object_get_field_by_name_boxed(rooted_opts.get(), key).to_bits()); if field.is_int32() { Some(((field.bits() & 0xFFFF_FFFF) as u32 as i32) as f64) } else if field.is_number() { @@ -54,16 +49,12 @@ unsafe fn opts_number_field(opts: f64, name: &str) -> Option { /// object field (e.g. `extend({ background })`). `None` if `obj` isn't an /// object. unsafe fn opts_field_bits(opts: f64, name: &str) -> Option { - let jv = JsValue::from_bits(opts.to_bits()); - if !jv.is_pointer() { - return None; - } - let obj = jv.as_pointer::(); - if obj.is_null() { - return None; - } + let scope = TransientRootScope::enter(); + let rooted_opts = scope.root_nanbox(opts); let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - Some(js_object_get_field_by_name_f64(obj, key)) + let field = + JsValue::from_bits(js_object_get_field_by_name_boxed(rooted_opts.get(), key).to_bits()); + (!field.is_undefined()).then(|| f64::from_bits(field.bits())) } /// Read a `{ r, g, b, alpha }` background colour from `opts.background`. @@ -74,8 +65,14 @@ unsafe fn read_background(opts: f64) -> image::Rgba { Some(b) => b, None => return image::Rgba([0, 0, 0, 255]), }; - let chan = |n: &str, d: f64| opts_number_field(bg, n).unwrap_or(d).clamp(0.0, 255.0) as u8; - let alpha = (opts_number_field(bg, "alpha") + let scope = TransientRootScope::enter(); + let rooted_bg = scope.root_nanbox(bg); + let chan = |n: &str, d: f64| { + opts_number_field(rooted_bg.get(), n) + .unwrap_or(d) + .clamp(0.0, 255.0) as u8 + }; + let alpha = (opts_number_field(rooted_bg.get(), "alpha") .unwrap_or(1.0) .clamp(0.0, 1.0) * 255.0) @@ -224,13 +221,82 @@ fn decode_image_bytes(bytes: &[u8]) -> Handle { } } -/// `sharp(input)` factory — `input` is a file path string OR a Buffer / -/// Uint8Array of encoded image bytes. The arg arrives as raw NaN-box bits -/// (NA_JSV); recover the underlying pointer and branch on the Buffer registry -/// probe. +const MAX_CREATE_DIMENSION: f64 = 100_000_000.0; +const MAX_CREATE_PIXELS: usize = 0x3FFF * 0x3FFF; + +fn valid_create_dimension(value: f64) -> Option { + (value.is_finite() && value.fract() == 0.0 && (1.0..=MAX_CREATE_DIMENSION).contains(&value)) + .then_some(value as u32) +} + +fn create_solid_image( + width: u32, + height: u32, + channels: u8, + background: image::Rgba, +) -> Option { + let pixel_count = (width as usize).checked_mul(height as usize)?; + if pixel_count > MAX_CREATE_PIXELS { + return None; + } + let byte_len = pixel_count.checked_mul(channels as usize)?; + let mut pixels = Vec::new(); + pixels.try_reserve_exact(byte_len).ok()?; + pixels.resize(byte_len, 0); + + match channels { + 3 => { + for pixel in pixels.as_chunks_mut::<3>().0 { + pixel.copy_from_slice(&background.0[..3]); + } + image::RgbImage::from_raw(width, height, pixels).map(DynamicImage::ImageRgb8) + } + 4 => { + for pixel in pixels.as_chunks_mut::<4>().0 { + pixel.copy_from_slice(&background.0); + } + image::RgbaImage::from_raw(width, height, pixels).map(DynamicImage::ImageRgba8) + } + _ => None, + } +} + +/// Decode sharp's object-form input descriptor: +/// `{ create: { width, height, channels, background: { r, g, b, alpha? } } }`. +/// +/// Sharp accepts only 3-channel RGB or 4-channel RGBA solid backgrounds. The +/// dimension bounds and default pixel limit mirror its constructor checks. +unsafe fn create_image_from_input(input: f64) -> Option { + let scope = TransientRootScope::enter(); + let rooted_input = scope.root_nanbox(input); + let create = opts_field_bits(rooted_input.get(), "create")?; + if !JsValue::from_bits(create.to_bits()).is_pointer() { + return None; + } + let rooted_create = scope.root_nanbox(create); + + let width = valid_create_dimension(opts_number_field(rooted_create.get(), "width")?)?; + let height = valid_create_dimension(opts_number_field(rooted_create.get(), "height")?)?; + let channels = opts_number_field(rooted_create.get(), "channels")?; + if !channels.is_finite() || channels.fract() != 0.0 || !matches!(channels as u8, 3 | 4) { + return None; + } + + let background = opts_field_bits(rooted_create.get(), "background")?; + if !JsValue::from_bits(background.to_bits()).is_pointer() { + return None; + } + let rgba = read_background(rooted_create.get()); + create_solid_image(width, height, channels as u8, rgba) +} + +/// `sharp(input)` factory — `input` is a file path string, a Buffer / +/// Uint8Array of encoded image bytes, or a `{ create: { ... } }` descriptor. +/// The arg arrives as raw NaN-box bits (NA_JSV); recover the underlying pointer +/// and branch on the input representation. /// /// # Safety -/// `input_bits` must be the raw NaN-box bits of a JS string or Buffer value. +/// `input_bits` must be the raw NaN-box bits of a supported JS input value. #[no_mangle] pub unsafe extern "C" fn js_sharp_from_input(input_bits: i64) -> Handle { let ptr = js_get_string_pointer_unified(f64::from_bits(input_bits as u64)); @@ -243,14 +309,17 @@ pub unsafe extern "C" fn js_sharp_from_input(input_bits: i64) -> Handle { None => -1, }; } - // A POINTER_TAG value that isn't a registered Buffer is a plain object / - // array — not a valid sharp input. `js_get_string_pointer_unified` hands - // back its heap pointer, which must NOT be read as a `StringHeader` (that - // would read arbitrary memory). Reject it the way sharp rejects an - // unsupported input. (Strings — long or short — and number-coerced keys - // are not `POINTER_TAG`, so the path-string case still flows through.) - if JsValue::from_bits(input_bits as u64).is_pointer() { - return -1; + let input = JsValue::from_bits(input_bits as u64); + if input.is_pointer() { + return match create_image_from_input(f64::from_bits(input.bits())) { + Some(image) => register_handle(SharpHandle { + image, + format: ImageFormat::Png, + quality: 80, + orientation: 1, + }), + None => -1, + }; } match read_string(JsString::from_raw(ptr as *mut StringHeader)) { Some(path) => open_image_path(path), @@ -839,6 +908,35 @@ mod tests { use super::*; use image::{ImageBuffer, Rgba}; + unsafe fn object(fields: &[(&str, JsValue)]) -> JsValue { + let keys: Vec<&str> = fields.iter().map(|(key, _)| *key).collect(); + let (packed, shape_id) = build_object_shape(&keys); + let obj = js_object_alloc_with_shape( + shape_id, + fields.len() as u32, + packed.as_ptr(), + packed.len() as u32, + ); + for (index, (_, value)) in fields.iter().enumerate() { + js_object_set_field(obj, index as u32, *value); + } + JsValue::from_object_ptr(obj) + } + + unsafe fn create_input( + width: JsValue, + height: JsValue, + channels: JsValue, + background: Option, + ) -> JsValue { + let mut fields = vec![("width", width), ("height", height), ("channels", channels)]; + if let Some(background) = background { + fields.push(("background", background)); + } + let create = object(&fields); + object(&[("create", create)]) + } + fn make_handle(w: u32, h: u32) -> Handle { let buf: ImageBuffer, Vec> = ImageBuffer::from_pixel(w, h, Rgba([255, 0, 0, 255])); @@ -896,6 +994,86 @@ mod tests { assert_eq!(js_sharp_height(-1), 0.0); } + #[test] + fn create_input_builds_rgb_canvas() { + unsafe { + let background = object(&[ + ("r", JsValue::from_int32(1)), + ("g", JsValue::from_number(2.0)), + ("b", JsValue::from_int32(3)), + ]); + let input = create_input( + JsValue::from_int32(4), + JsValue::from_number(3.0), + JsValue::from_int32(3), + Some(background), + ); + + let handle = js_sharp_from_input(input.bits() as i64); + let sharp = get_handle::(handle).expect("valid create handle"); + assert_eq!(sharp.image.dimensions(), (4, 3)); + assert_eq!(sharp.image.color().channel_count(), 3); + assert_eq!(sharp.image.to_rgb8().get_pixel(3, 2).0, [1, 2, 3]); + } + } + + #[test] + fn create_input_preserves_rgba_alpha() { + unsafe { + let background = object(&[ + ("r", JsValue::from_int32(10)), + ("g", JsValue::from_int32(20)), + ("b", JsValue::from_int32(30)), + ("alpha", JsValue::from_number(0.5)), + ]); + let input = create_input( + JsValue::from_int32(2), + JsValue::from_int32(1), + JsValue::from_int32(4), + Some(background), + ); + + let handle = js_sharp_from_input(input.bits() as i64); + let sharp = get_handle::(handle).expect("valid create handle"); + assert_eq!(sharp.image.color().channel_count(), 4); + assert_eq!(sharp.image.to_rgba8().get_pixel(1, 0).0, [10, 20, 30, 128]); + } + } + + #[test] + fn create_input_rejects_invalid_descriptors() { + unsafe { + let background = object(&[ + ("r", JsValue::from_int32(1)), + ("g", JsValue::from_int32(2)), + ("b", JsValue::from_int32(3)), + ]); + let invalid_width = create_input( + JsValue::from_number(1.5), + JsValue::from_int32(4), + JsValue::from_int32(3), + Some(background), + ); + assert_eq!(js_sharp_from_input(invalid_width.bits() as i64), -1); + + let invalid_channels = create_input( + JsValue::from_int32(4), + JsValue::from_int32(4), + JsValue::from_int32(2), + Some(background), + ); + assert_eq!(js_sharp_from_input(invalid_channels.bits() as i64), -1); + + let missing_background = create_input( + JsValue::from_int32(4), + JsValue::from_int32(4), + JsValue::from_int32(3), + None, + ); + assert_eq!(js_sharp_from_input(missing_background.bits() as i64), -1); + } + } + #[test] fn invalid_handle_async_failure_is_an_error_object() { let promise = js_sharp_metadata(perry_ffi::INVALID_HANDLE); diff --git a/crates/perry-ext-sharp/src/test_async_shims.rs b/crates/perry-ext-sharp/src/test_async_shims.rs index a5b2afdab1..f91f07aae5 100644 --- a/crates/perry-ext-sharp/src/test_async_shims.rs +++ b/crates/perry-ext-sharp/src/test_async_shims.rs @@ -1,6 +1,6 @@ //! Test-only host shims for the standalone sharp extension test binary. -use perry_ffi::Promise; +use perry_ffi::{NativeAsyncCompletion, Promise}; use std::ffi::c_void; #[no_mangle] @@ -8,16 +8,29 @@ pub extern "C" fn perry_ffi_promise_new() -> *mut Promise { perry_runtime::promise::js_promise_new() as *mut Promise } +#[no_mangle] +pub extern "C" fn perry_ffi_promise_resolve_bits(promise: *mut Promise, bits: u64) { + perry_runtime::promise::js_promise_resolve( + promise as *mut perry_runtime::Promise, + f64::from_bits(bits), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64) { + perry_runtime::promise::js_promise_reject( + promise as *mut perry_runtime::Promise, + f64::from_bits(bits), + ); +} + #[no_mangle] pub extern "C" fn perry_ffi_promise_resolve_deferred( promise: *mut Promise, ctx: *mut c_void, invoke: extern "C" fn(*mut c_void) -> u64, ) { - perry_runtime::promise::js_promise_resolve( - promise as *mut perry_runtime::Promise, - f64::from_bits(invoke(ctx)), - ); + perry_ffi_promise_resolve_bits(promise, invoke(ctx)); } #[no_mangle] @@ -26,13 +39,72 @@ pub extern "C" fn perry_ffi_promise_reject_deferred( ctx: *mut c_void, invoke: extern "C" fn(*mut c_void) -> u64, ) { - perry_runtime::promise::js_promise_reject( - promise as *mut perry_runtime::Promise, - f64::from_bits(invoke(ctx)), - ); + perry_ffi_promise_reject_bits(promise, invoke(ctx)); } #[no_mangle] pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) { invoke(ctx); } + +#[no_mangle] +pub extern "C" fn perry_ffi_spawn_blocking_with_reactor( + ctx: *mut c_void, + invoke: extern "C" fn(*mut c_void), +) { + invoke(ctx); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_new(_flags: u32) -> *mut NativeAsyncCompletion { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_promise( + _token: *mut NativeAsyncCompletion, +) -> *mut Promise { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_resolve_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_string( + _token: *mut NativeAsyncCompletion, + _data: *const u8, + _len: usize, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_cancel(_token: *mut NativeAsyncCompletion) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_attach_handle( + _token: *mut NativeAsyncCompletion, + _handle_bits: u64, + _cleanup_flags: u32, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} diff --git a/crates/perry/tests/issue_8748_sharp_create.rs b/crates/perry/tests/issue_8748_sharp_create.rs new file mode 100644 index 0000000000..d3ed2e1209 --- /dev/null +++ b/crates/perry/tests/issue_8748_sharp_create.rs @@ -0,0 +1,103 @@ +//! Regression test for #8748: Sharp's object-form `create` input must produce +//! a real image handle that remains usable through a fluent encode pipeline. + +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn run_with_timeout(mut command: Command, timeout: Duration) -> Output { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().expect("run compiled binary"); + let start = Instant::now(); + loop { + if child.try_wait().expect("poll compiled binary").is_some() { + return child + .wait_with_output() + .expect("collect compiled binary output"); + } + if start.elapsed() >= timeout { + child.kill().expect("kill timed out compiled binary"); + let output = child + .wait_with_output() + .expect("collect timed out compiled binary output"); + panic!( + "compiled binary timed out after {timeout:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[test] +fn sharp_create_encodes_and_decodes_png() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +import sharp from "sharp"; + +const rgb = await sharp({ + create: { + width: 4, + height: 3, + channels: 3, + background: { r: 1, g: 2, b: 3 }, + }, +}).png().toBuffer(); +const rgbMetadata = await sharp(rgb).metadata(); +console.log(rgbMetadata.format, rgbMetadata.width, rgbMetadata.height, rgbMetadata.channels, rgb.length > 0); + +const rgba = await sharp({ + create: { + width: 2, + height: 1, + channels: 4, + background: { r: 10, g: 20, b: 30, alpha: 0.5 }, + }, +}).png().toBuffer(); +const rgbaMetadata = await sharp(rgba).metadata(); +console.log(rgbaMetadata.format, rgbaMetadata.width, rgbaMetadata.height, rgbaMetadata.channels, rgbaMetadata.hasAlpha); +process.exit(0); +"#, + ) + .expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("--no-cache") + .arg("-o") + .arg(&output) + .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 mut run_command = Command::new(&output); + run_command.current_dir(dir.path()); + let run = run_with_timeout(run_command, Duration::from_secs(30)); + 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) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "png 4 3 3 true\npng 2 1 4 true\n" + ); +} diff --git a/docs/src/stdlib/other.md b/docs/src/stdlib/other.md index b8b9707307..f28b1d7c28 100644 --- a/docs/src/stdlib/other.md +++ b/docs/src/stdlib/other.md @@ -20,6 +20,17 @@ const buf = await sharp("input.jpg") await sharp("input.png") .resize(300, 200) .toFile("output.png"); + +const placeholder = await sharp({ + create: { + width: 300, + height: 200, + channels: 4, + background: { r: 30, g: 41, b: 59, alpha: 1 }, + }, +}) + .png() + .toBuffer(); ``` ## cheerio (HTML Parsing) From 4d67615ea8f50b455af5c3b20039bb6bf21fb2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 12:40:53 +0200 Subject: [PATCH 05/13] perf(codegen): specialize call-returned array stores --- .../src/expr/call_return_array_index_tests.rs | 175 ++++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 2 + .../perry-codegen/src/expr/proxy_reflect.rs | 10 +- crates/perry-runtime/src/array/indexing.rs | 50 ++++- crates/perry/tests/call_return_array_index.rs | 149 +++++++++++++++ 5 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 crates/perry-codegen/src/expr/call_return_array_index_tests.rs create mode 100644 crates/perry/tests/call_return_array_index.rs diff --git a/crates/perry-codegen/src/expr/call_return_array_index_tests.rs b/crates/perry-codegen/src/expr/call_return_array_index_tests.rs new file mode 100644 index 0000000000..68802a99f7 --- /dev/null +++ b/crates/perry-codegen/src/expr/call_return_array_index_tests.rs @@ -0,0 +1,175 @@ +use crate::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Class, Expr, Function, Module, Param, Stmt}; + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn function( + id: u32, + name: &str, + params: Vec, + return_type: Type, + body: Vec, +) -> Function { + Function { + id, + name: name.to_string(), + type_params: Vec::new(), + params, + return_type, + body, + 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 call_get_data(selector: i64) -> Expr { + Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "getData".to_string(), + byte_offset: 0, + }), + args: vec![Expr::Integer(selector)], + type_args: Vec::new(), + byte_offset: 0, + } +} + +fn store_class(receiver_selector: i64) -> Class { + let get_data = function( + 2, + "getData", + vec![param(3, "selector", Type::Number)], + Type::Array(Box::new(Type::Any)), + vec![Stmt::Return(Some(Expr::Array(vec![Expr::Number(0.0)])))], + ); + let write = function( + 3, + "write", + vec![ + param(1, "index", Type::Number), + param(2, "value", Type::Any), + ], + Type::Void, + vec![Stmt::Expr(Expr::PutValueSet { + target: Box::new(call_get_data(0)), + key: Box::new(Expr::LocalGet(1)), + value: Box::new(Expr::LocalGet(2)), + receiver: Box::new(call_get_data(receiver_selector)), + strict: true, + })], + ); + Class { + id: 1, + name: "Store".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![get_data, write], + 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_store_ir(receiver_selector: i64) -> String { + let mut module = Module::new("call_return_array_put_value.ts"); + module.classes.push(store_class(receiver_selector)); + let bytes = compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ) + .expect("call-returned array store compiles"); + String::from_utf8(bytes).expect("LLVM IR is UTF-8") +} + +fn write_method_ir(ir: &str) -> &str { + let signature = "define double @perry_method_call_return_array_put_value_ts__Store__write("; + let start = ir.find(signature).expect("write method is present in IR"); + let method_and_rest = &ir[start..]; + let end = method_and_rest + .find("\n}\n") + .expect("write method has a closing brace"); + &method_and_rest[..end + 3] +} + +#[test] +fn same_call_returned_array_uses_array_index_store_and_evaluates_receiver_once() { + let ir = compile_store_ir(0); + let write_ir = write_method_ir(&ir); + + assert!( + write_ir.contains("call i64 @js_typed_feedback_array_set_index_or_string("), + "a call with an Array return type must use the array-index semantic fallback:\n{write_ir}" + ); + assert!( + !write_ir.contains("call double @js_put_value_set_dyn_ic("), + "the proven array receiver must not enter the generic Proxy-compatible PutValue ladder:\n{write_ir}" + ); + assert_eq!( + write_ir + .matches( + "call double @perry_method_call_return_array_put_value_ts__Store__getData(" + ) + .count(), + 1, + "the syntactically duplicated target/receiver call represents one evaluated assignment base" + ); +} + +#[test] +fn distinct_call_receiver_stays_on_explicit_receiver_put_value_path() { + let ir = compile_store_ir(1); + let write_ir = write_method_ir(&ir); + + assert!( + !write_ir.contains("call i64 @js_typed_feedback_array_set_index_or_string("), + "a receiver that differs from the target must not use same-receiver array lowering:\n{write_ir}" + ); + assert!( + write_ir.contains("call double @js_put_value_set("), + "the distinct receiver must be passed to the generic PutValue helper:\n{write_ir}" + ); + assert_eq!( + write_ir + .matches("call double @perry_method_call_return_array_put_value_ts__Store__getData(") + .count(), + 2, + "target and distinct receiver calls are independently evaluated" + ); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index dbfed78b52..5cb84eb1b5 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -161,6 +161,8 @@ mod write_pic_barrier_tests; // temp alloca through the same shadow-slot emission every named local uses, // and it now lives outside `crate::expr`. #[cfg(test)] +mod call_return_array_index_tests; +#[cfg(test)] mod call_spread_rooting_tests; mod call_spread_short; #[cfg(test)] diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 8e0a0450c5..0d3bca3bc4 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -1260,7 +1260,15 @@ fn is_numeric_string_key(key: &str) -> bool { } fn put_value_index_fast_path(ctx: &FnCtx<'_>, target: &Expr, key: &Expr, receiver: &Expr) -> bool { - if !same_side_effect_free_receiver(target, receiver) { + // `PutValueSet` stores the assignment base in both `target` and `receiver`; + // those two HIR trees describe one source evaluation, not two evaluations + // that may be coalesced only when pure. Use the same structural-identity + // check as the generic same-receiver PutValue lowering below so expressions + // such as `this.getData()[index] = value` can retain the statically known + // Array type. `IndexSet::lower` evaluates that base once. A genuinely + // distinct receiver (including a call with different arguments) still + // fails closed to the explicit-receiver runtime path. + if !same_put_value_receiver_expr(target, receiver) { return false; } if is_array_expr(ctx, target) { diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index d6a25b9343..efde64e781 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -1084,10 +1084,11 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64 /// (`index_set` / `index` / `field_set_by_name`) routes here. /// test262 built-ins/Array element/add on frozen|sealed|non-extensible. /// Strict-mode guard for a would-be `arr[index] = v` element write: throws the -/// spec `Set`-with-`Throw` TypeError when `arr` is frozen (existing index → -/// read-only) or non-extensible and the index is new (→ not-extensible). No-op -/// for writable slots, buffers, and typed arrays (which own their store -/// semantics). Shared by the strict element-write entry points. +/// spec `Set`-with-`Throw` TypeError when an own data descriptor is read-only, +/// an accessor has no setter, `length` is read-only and would grow, the array +/// is frozen, or a non-extensible array would gain a new element. No-op for +/// writable slots, buffers, and typed arrays (which own their store semantics). +/// Shared by the strict element-write entry points. #[inline] pub(crate) fn array_strict_index_write_guard(arr: *mut ArrayHeader, index: u32) { let clean = clean_arr_ptr_mut(arr); @@ -1099,12 +1100,49 @@ pub(crate) fn array_strict_index_write_guard(arr: *mut ArrayHeader, index: u32) } let flags = array_object_flags(clean); let length = unsafe { (*clean).length }; + + // A descriptor-bearing array is rare, so keep all key construction and + // side-table probes off the ordinary dense-array path. An accessor with a + // setter remains writable even when the object is frozen; return early and + // let `js_array_set_f64_extend` invoke it. Every other rejected descriptor + // must throw here because that lower-level helper deliberately retains a + // silent contract for internal DefineOwnProperty callers. + if flags & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 { + let key = index.to_string(); + if let Some(accessor) = crate::object::get_accessor_descriptor(clean as usize, &key) { + if accessor.set == 0 { + throw_frozen_array_index_write(index); + } + return; + } + if crate::object::get_property_attrs(clean as usize, &key) + .is_some_and(|attrs| !attrs.writable()) + { + throw_frozen_array_index_write(index); + } + if index >= length + && crate::object::get_property_attrs(clean as usize, "length") + .is_some_and(|attrs| !attrs.writable()) + { + crate::collection_iter::throw_type_error( + "Cannot assign to read only property 'length' of object '[object Array]'", + ); + } + } + if index < length { - // Existing index: only a *frozen* array's data is non-writable; a - // sealed / non-extensible array still permits overwriting it. if flags & crate::gc::OBJ_FLAG_FROZEN != 0 { throw_frozen_array_index_write(index); } + // `length` includes holes. Filling one creates a new own property, so + // sealed/preventExtensions arrays must reject it even though the index + // is numerically in bounds. This probe is confined to the already-cold + // restricted-object branch. + if flags & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) != 0 + && !unsafe { array_has_own_index(clean, index) } + { + throw_array_not_extensible_add(index); + } } else if flags & (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) != 0 diff --git a/crates/perry/tests/call_return_array_index.rs b/crates/perry/tests/call_return_array_index.rs new file mode 100644 index 0000000000..fe5e9732e0 --- /dev/null +++ b/crates/perry/tests/call_return_array_index.rs @@ -0,0 +1,149 @@ +//! Executable semantics for array-index stores whose assignment base is a +//! call expression. The HIR repeats that call as the PutValue target and +//! receiver, but the source evaluates it exactly once. + +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 call_returned_array_store_preserves_evaluation_proxy_and_descriptor_semantics() { + let stdout = compile_and_run( + r#" +class Store { + calls = 0; + constructor(public data: any[]) {} + + getData(): any[] { + this.calls++; + return this.data; + } + + write(index: number, value: any): any { + return this.getData()[index] = value; + } +} + +const plain: any[] = [10, 20]; +const store = new Store(plain); +console.log("integer", store.write(1, 41), plain[1], store.calls); +console.log("fractional", store.write(1.5, 77), plain["1.5"], plain.length, store.calls); + +const traps: string[] = []; +const target: any[] = [1, 2]; +const proxy: any[] = new Proxy(target, { + set(t: any, key: any, value: any, receiver: any) { + traps.push(String(key) + ":" + String(value)); + return Reflect.set(t, key, value, receiver); + }, +}); +const proxyStore = new Store(proxy); +console.log("proxy", proxyStore.write(0, 9), target[0], proxyStore.calls, traps.join(",")); + +const locked: any[] = [5]; +Object.defineProperty(locked, "0", { value: 5, writable: false }); +const lockedStore = new Store(locked); +let rejected = false; +try { + lockedStore.write(0, 8); +} catch (_error) { + rejected = true; +} +console.log("locked", rejected, locked[0], lockedStore.calls); + +const getterOnly: any[] = [6]; +Object.defineProperty(getterOnly, "0", { get() { return 6; } }); +const getterStore = new Store(getterOnly); +rejected = false; +try { + getterStore.write(0, 8); +} catch (_error) { + rejected = true; +} +console.log("getter-only", rejected, getterOnly[0], getterStore.calls); + +const accessor: any[] = [4]; +let setterValue = 0; +Object.defineProperty(accessor, "0", { + get() { return setterValue; }, + set(value: any) { setterValue = value; }, +}); +const accessorStore = new Store(accessor); +console.log("setter", accessorStore.write(0, 12), accessor[0], accessorStore.calls); + +const hole: any[] = [1, 2, 3]; +delete hole[1]; +Object.preventExtensions(hole); +const holeStore = new Store(hole); +rejected = false; +try { + holeStore.write(1, 9); +} catch (_error) { + rejected = true; +} +console.log("sealed-hole", rejected, hole[1], holeStore.calls); + +const fixedLength: any[] = [1]; +Object.defineProperty(fixedLength, "length", { writable: false }); +const fixedLengthStore = new Store(fixedLength); +rejected = false; +try { + fixedLengthStore.write(1, 2); +} catch (_error) { + rejected = true; +} +console.log("fixed-length", rejected, fixedLength.length, fixedLengthStore.calls); +"#, + ); + + assert_eq!( + stdout, + "integer 41 41 1\n\ + fractional 77 77 2 2\n\ + proxy 9 9 1 0:9\n\ + locked true 5 1\n\ + getter-only true 6 1\n\ + setter 12 12 1\n\ + sealed-hole true undefined 1\n\ + fixed-length true 1 1\n" + ); +} From fd018181ce447399cef1e92e536f101c6006cf34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 11:34:28 +0200 Subject: [PATCH 06/13] perf(map): repair ordered-delete indexes in place --- crates/perry-runtime/src/gc/barrier/mod.rs | 2 +- crates/perry-runtime/src/map.rs | 225 ++++++++++++++++----- crates/perry-runtime/src/set.rs | 4 +- 3 files changed, 174 insertions(+), 57 deletions(-) diff --git a/crates/perry-runtime/src/gc/barrier/mod.rs b/crates/perry-runtime/src/gc/barrier/mod.rs index 067becd4fa..005edd4091 100644 --- a/crates/perry-runtime/src/gc/barrier/mod.rs +++ b/crates/perry-runtime/src/gc/barrier/mod.rs @@ -1932,7 +1932,7 @@ pub(crate) fn runtime_store_external_jsvalue_slot_with_layout( runtime_write_barrier_external_slot(parent_user, slot_addr, value_bits); } -pub(crate) fn runtime_dirty_external_slot_span( +pub(crate) fn runtime_write_barrier_external_slot_span( parent_addr: usize, first_slot_addr: usize, slot_count: usize, diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index d49184a77f..96133f40b7 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -1365,7 +1365,7 @@ unsafe fn map_set_string_key_value( let size = (*map).size; let entries = entries_ptr_mut(map); if grew && size > 0 { - crate::gc::runtime_dirty_external_slot_span( + crate::gc::runtime_write_barrier_external_slot_span( map as usize, entries as usize, size as usize * 2, @@ -1474,7 +1474,7 @@ fn map_set_resolved(map: *mut MapHeader, key: f64, value: f64) { let size = (*map).size; let entries = entries_ptr_mut(map); if grew && size > 0 { - crate::gc::runtime_dirty_external_slot_span( + crate::gc::runtime_write_barrier_external_slot_span( map as usize, entries as usize, size as usize * 2, @@ -1843,79 +1843,92 @@ unsafe fn delete_entry_at_index(map: *mut MapHeader, idx: i32) -> i32 { return 0; } let entries = entries_ptr_mut(map); + let deleted_key = ptr::read(entries.add(idx * 2)); // #2831: preserve insertion order. JS Map iteration must keep the // relative order of surviving entries after a delete (and a // delete-then-re-add appends at the end). The previous swap-and-pop - // moved the last entry into the hole, reordering iteration. Shift - // every entry after `idx` down by one slot instead. - for i in idx..(size as usize - 1) { - let next_key = ptr::read(entries.add((i + 1) * 2)); - let next_value = ptr::read(entries.add((i + 1) * 2 + 1)); - // GC_STORE_AUDIT(EXTERNAL_BARRIERED): map compaction slots use the shared external-slot helper. - crate::gc::runtime_store_external_jsvalue_slot( - map as usize, - entries.add(i * 2) as usize, - next_key.to_bits(), + // moved the last entry into the hole, reordering iteration. Compact the + // already-owned key/value pairs with one overlap-safe move. This does not + // create a new parent -> child edge: every copied value was already in + // this Map. The span mark preserves the old -> young remembered-set + // contract for the slots' new addresses without paying two full runtime + // stores per entry. + let moved_entries = size as usize - idx - 1; + if moved_entries > 0 { + // GC_STORE_AUDIT(EXTERNAL_BARRIERED): ordered compaction is followed by a dirty-span barrier for every moved slot. + ptr::copy( + entries.add((idx + 1) * 2), + entries.add(idx * 2), + moved_entries * 2, ); - crate::gc::runtime_store_external_jsvalue_slot( + crate::gc::runtime_write_barrier_external_slot_span( map as usize, - entries.add(i * 2 + 1) as usize, - next_value.to_bits(), + entries.add(idx * 2) as usize, + moved_entries * 2, ); } (*map).size = size - 1; - // The shift changes the entry index of every surviving key at or - // after `idx`, so the O(1) lookup side-tables can't be patched in - // place cheaply. Rebuild them from the compacted buffer. - rebuild_map_index(map); + // The old implementation rebuilt all three indexes from the entries + // buffer after every ordered delete. Repair their existing u32 offsets + // in place instead: removing one key and decrementing later offsets is a + // cache-linear pass over index values and does not re-hash surviving keys. + repair_map_indices_after_ordered_delete(map, deleted_key, idx as u32); 1 } -/// Rebuild the numeric + string lookup side-tables for `map` from its -/// current compacted entries buffer. Used after an order-preserving -/// `delete` shifts entry indexes (#2831). -unsafe fn rebuild_map_index(map: *mut MapHeader) { - if map.is_null() { - return; - } - let size = (*map).size as usize; - let capacity = (*map).capacity as usize; - if size > capacity || size > 16_000_000 || (*map).entries.is_null() { - return; - } - let entries = entries_ptr(map); - MAP_INDEX.with(|idx| { - let mut idx = idx.borrow_mut(); - let slot = idx - .entry(map as usize) - .or_insert_with(crate::fast_hash::new_ptr_hash_map); - slot.clear(); - for i in 0..size { - let key_bits = ptr::read(entries.add(i * 2)).to_bits(); - if is_safe_numeric_key(key_bits) { - slot.insert(NumericKey(key_bits), i as u32); +unsafe fn repair_map_indices_after_ordered_delete( + map: *mut MapHeader, + deleted_key: f64, + deleted_idx: u32, +) { + let map_addr = map as usize; + let deleted_bits = deleted_key.to_bits(); + + MAP_INDEX.with(|indexes| { + let mut indexes = indexes.borrow_mut(); + if let Some(index) = indexes.get_mut(&map_addr) { + if is_safe_numeric_key(deleted_bits) { + index.remove(&NumericKey(deleted_bits)); + } + for entry_idx in index.values_mut() { + if *entry_idx > deleted_idx { + *entry_idx -= 1; + } } } }); - MAP_STRING_INDEX.with(|idx| { - let mut idx = idx.borrow_mut(); - let slot = idx - .entry(map as usize) - .or_insert_with(std::collections::HashMap::new); - slot.clear(); - for i in 0..size { - let key_bits = ptr::read(entries.add(i * 2)).to_bits(); - if is_string_like(key_bits) { - if let Some(h) = string_content_hash(key_bits) { - slot.entry(h).or_insert_with(Vec::new).push(i as u32); + + MAP_STRING_INDEX.with(|indexes| { + let mut indexes = indexes.borrow_mut(); + if let Some(index) = indexes.get_mut(&map_addr) { + for bucket in index.values_mut() { + bucket.retain(|entry_idx| *entry_idx != deleted_idx); + for entry_idx in bucket { + if *entry_idx > deleted_idx { + *entry_idx -= 1; + } + } + } + index.retain(|_, bucket| !bucket.is_empty()); + } + }); + + MAP_PTR_INDEX.with(|indexes| { + let mut indexes = indexes.borrow_mut(); + if let Some(index) = indexes.get_mut(&map_addr) { + if is_ptr_index_key(deleted_bits) { + index.remove(&MapPtrKey(deleted_key)); + } + for entry_idx in index.values_mut() { + if *entry_idx > deleted_idx { + *entry_idx -= 1; } } } }); - rebuild_map_ptr_index(map); } /// Rebuild ONLY the pointer-key index for `map` from its current entries @@ -2687,4 +2700,108 @@ mod tests { assert_eq!(js_map_delete_number_key(map, boxed_string_key), 1); assert_eq!(js_map_has(map, boxed_string_key), 0); } + + #[test] + fn ordered_delete_repairs_mixed_side_indexes_and_preserves_order() { + let map = js_map_alloc(32); + let string_keys = (0..12) + .map(|i| { + let bytes = format!("key-{i:02}").into_bytes(); + js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + }) + .collect::>(); + + for (i, string_key) in string_keys.iter().copied().enumerate() { + js_map_set(map, i as f64, (i * 10) as f64); + js_map_set_string_number(map, string_key, (i * 10 + 1) as f64); + } + // Keep the backing allocations alive while using their tagged + // addresses as identity keys. They deliberately are not GC objects: + // this exercises the pointer-key index without introducing an + // allocation/collection point into the ordered-delete fixture. + let pointer_owners = (0..4).map(Box::new).collect::>(); + let pointer_keys = pointer_owners + .iter() + .map(|owner| { + f64::from_bits( + crate::value::POINTER_TAG + | ((owner.as_ref() as *const i32 as u64) & crate::value::POINTER_MASK), + ) + }) + .collect::>(); + for (i, key) in pointer_keys.iter().copied().enumerate() { + js_map_set(map, key, (1_000 + i) as f64); + } + assert_eq!(js_map_size(map), 28); + + assert_eq!(js_map_delete_number_key(map, 2.0), 1); + assert_eq!(js_map_delete_string_key(map, string_keys[4]), 1); + assert_eq!(js_map_delete(map, pointer_keys[1]), 1); + assert_eq!(js_map_size(map), 25); + assert_eq!(js_map_has_number_key(map, 2.0), 0); + assert_eq!(js_map_has_string_key(map, string_keys[4]), 0); + assert_eq!(js_map_has(map, pointer_keys[1]), 0); + + for (i, string_key) in string_keys.iter().copied().enumerate() { + if i != 2 { + assert_eq!(js_map_get_number_key(map, i as f64), (i * 10) as f64); + assert!(test_map_numeric_index_contains(map, i as f64)); + } + if i != 4 { + assert_eq!(js_map_get_string_key(map, string_key), (i * 10 + 1) as f64); + assert!(test_map_string_index_contains( + map, + boxed_heap_string_key(string_key) + )); + } + } + for (i, key) in pointer_keys.iter().copied().enumerate() { + if i != 1 { + assert_eq!(js_map_get(map, key), (1_000 + i) as f64); + assert!(test_map_ptr_index_contains(map, key)); + } + } + + let mut expected_keys = (0..12) + .flat_map(|i| { + let mut keys = Vec::new(); + if i != 2 { + keys.push((i as f64).to_bits()); + } + if i != 4 { + keys.push(boxed_heap_string_key(string_keys[i]).to_bits()); + } + keys + }) + .collect::>(); + expected_keys.extend( + pointer_keys + .iter() + .enumerate() + .filter(|(i, _)| *i != 1) + .map(|(_, key)| key.to_bits()), + ); + let actual_keys = (0..js_map_size(map)) + .map(|i| js_map_entry_key_at(map, i).to_bits()) + .collect::>(); + assert_eq!( + actual_keys, expected_keys, + "delete must preserve survivor order" + ); + + js_map_set_number_key(map, 2.0, 222.0); + js_map_set_string_number(map, string_keys[4], 444.0); + js_map_set(map, pointer_keys[1], 1_111.0); + assert_eq!(js_map_size(map), 28); + assert_eq!(js_map_entry_key_at(map, 25).to_bits(), 2.0f64.to_bits()); + assert_eq!( + js_map_entry_key_at(map, 26).to_bits(), + boxed_heap_string_key(string_keys[4]).to_bits(), + "delete-then-re-add must append at the end" + ); + assert_eq!( + js_map_entry_key_at(map, 27).to_bits(), + pointer_keys[1].to_bits() + ); + } } diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 8d381f900c..f83afc1169 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -988,7 +988,7 @@ fn set_add_resolved(set: *mut SetHeader, value: f64) { let size = (*set).size; let elements = elements_ptr_mut(set); if grew && size > 0 { - crate::gc::runtime_dirty_external_slot_span( + crate::gc::runtime_write_barrier_external_slot_span( set as usize, elements as usize, size as usize, @@ -1045,7 +1045,7 @@ fn set_add_string_resolved(set: *mut SetHeader, value: *const StringHeader) { let size = (*set).size; let elements = elements_ptr_mut(set); if grew && size > 0 { - crate::gc::runtime_dirty_external_slot_span( + crate::gc::runtime_write_barrier_external_slot_span( set as usize, elements as usize, size as usize, From 990d1f4f1a8693e414dcd001c2ad45c3c01364e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 11:35:18 +0200 Subject: [PATCH 07/13] chore: add changelog for map delete optimization --- changelog.d/8813-map-ordered-delete.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8813-map-ordered-delete.md diff --git a/changelog.d/8813-map-ordered-delete.md b/changelog.d/8813-map-ordered-delete.md new file mode 100644 index 0000000000..392760d82c --- /dev/null +++ b/changelog.d/8813-map-ordered-delete.md @@ -0,0 +1 @@ +Ordered `Map.delete` now compacts surviving entries with one overlap-safe move and repairs numeric, string, and pointer side-index offsets in place instead of barrier-storing and rehashing every survivor. Insertion order, SameValueZero lookup, delete-then-readd ordering, moving-GC pointer-index rebuilds, and old-to-young external-slot tracking are preserved. From 2426ebf7b94779b36df27a3f950d2500298b3dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 12:43:57 +0200 Subject: [PATCH 08/13] test(map): root ordered-delete string keys --- crates/perry-runtime/src/map.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 96133f40b7..84352f507b 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -2704,15 +2704,26 @@ mod tests { #[test] fn ordered_delete_repairs_mixed_side_indexes_and_preserves_order() { let map = js_map_alloc(32); + let scope = crate::gc::RuntimeHandleScope::new(); let string_keys = (0..12) .map(|i| { let bytes = format!("key-{i:02}").into_bytes(); - js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) + scope.root_nanbox_f64(boxed_heap_string_key(js_string_from_bytes( + bytes.as_ptr(), + bytes.len() as u32, + ))) }) .collect::>(); - for (i, string_key) in string_keys.iter().copied().enumerate() { + let string_key_ptr = |i: usize| { + (string_keys[i].get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) + as *const StringHeader + }; + + for (i, string_key) in string_keys.iter().enumerate() { js_map_set(map, i as f64, (i * 10) as f64); + let string_key = (string_key.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) + as *const StringHeader; js_map_set_string_number(map, string_key, (i * 10 + 1) as f64); } // Keep the backing allocations alive while using their tagged @@ -2735,19 +2746,22 @@ mod tests { assert_eq!(js_map_size(map), 28); assert_eq!(js_map_delete_number_key(map, 2.0), 1); - assert_eq!(js_map_delete_string_key(map, string_keys[4]), 1); + assert_eq!(js_map_delete_string_key(map, string_key_ptr(4)), 1); assert_eq!(js_map_delete(map, pointer_keys[1]), 1); assert_eq!(js_map_size(map), 25); assert_eq!(js_map_has_number_key(map, 2.0), 0); - assert_eq!(js_map_has_string_key(map, string_keys[4]), 0); + assert_eq!(js_map_has_string_key(map, string_key_ptr(4)), 0); assert_eq!(js_map_has(map, pointer_keys[1]), 0); - for (i, string_key) in string_keys.iter().copied().enumerate() { + for (i, string_key) in string_keys.iter().enumerate() { if i != 2 { assert_eq!(js_map_get_number_key(map, i as f64), (i * 10) as f64); assert!(test_map_numeric_index_contains(map, i as f64)); } if i != 4 { + let string_key = (string_key.get_nanbox_f64().to_bits() + & crate::value::POINTER_MASK) + as *const StringHeader; assert_eq!(js_map_get_string_key(map, string_key), (i * 10 + 1) as f64); assert!(test_map_string_index_contains( map, @@ -2769,7 +2783,7 @@ mod tests { keys.push((i as f64).to_bits()); } if i != 4 { - keys.push(boxed_heap_string_key(string_keys[i]).to_bits()); + keys.push(string_keys[i].get_nanbox_f64().to_bits()); } keys }) @@ -2790,13 +2804,13 @@ mod tests { ); js_map_set_number_key(map, 2.0, 222.0); - js_map_set_string_number(map, string_keys[4], 444.0); + js_map_set_string_number(map, string_key_ptr(4), 444.0); js_map_set(map, pointer_keys[1], 1_111.0); assert_eq!(js_map_size(map), 28); assert_eq!(js_map_entry_key_at(map, 25).to_bits(), 2.0f64.to_bits()); assert_eq!( js_map_entry_key_at(map, 26).to_bits(), - boxed_heap_string_key(string_keys[4]).to_bits(), + string_keys[4].get_nanbox_f64().to_bits(), "delete-then-re-add must append at the end" ); assert_eq!( From 2964c2535314b802ece6a973df8c71864bb1060d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:47:00 +0200 Subject: [PATCH 09/13] feat(qs): add native Stripe-compatible shim (#8751) --- Cargo.lock | 9 + Cargo.toml | 2 + changelog.d/8751-native-qs.md | 1 + crates/perry-api-manifest/src/entries.rs | 1 + .../perry-api-manifest/src/entries/part_4.rs | 19 + crates/perry-codegen/src/ext_registry.rs | 3 + .../src/lower_call/native_table/mod.rs | 2 + .../src/lower_call/native_table/qs.rs | 22 + crates/perry-ext-qs/Cargo.toml | 20 + crates/perry-ext-qs/src/codec.rs | 128 +++++ crates/perry-ext-qs/src/lib.rs | 46 ++ crates/perry-ext-qs/src/options.rs | 360 ++++++++++++++ crates/perry-ext-qs/src/parse.rs | 449 ++++++++++++++++++ crates/perry-ext-qs/src/runtime.rs | 134 ++++++ crates/perry-ext-qs/src/stringify.rs | 330 +++++++++++++ crates/perry-ext-qs/src/test_async_shims.rs | 113 +++++ .../perry/src/commands/compile/well_known.rs | 2 +- .../perry/tests/issue_8751_qs_native_shim.rs | 154 ++++++ crates/perry/well_known_bindings.toml | 17 + docs/src/native-libraries/governance.md | 1 + workspace-architecture.json | 9 +- 21 files changed, 1819 insertions(+), 3 deletions(-) create mode 100644 changelog.d/8751-native-qs.md create mode 100644 crates/perry-codegen/src/lower_call/native_table/qs.rs create mode 100644 crates/perry-ext-qs/Cargo.toml create mode 100644 crates/perry-ext-qs/src/codec.rs create mode 100644 crates/perry-ext-qs/src/lib.rs create mode 100644 crates/perry-ext-qs/src/options.rs create mode 100644 crates/perry-ext-qs/src/parse.rs create mode 100644 crates/perry-ext-qs/src/runtime.rs create mode 100644 crates/perry-ext-qs/src/stringify.rs create mode 100644 crates/perry-ext-qs/src/test_async_shims.rs create mode 100644 crates/perry/tests/issue_8751_qs_native_shim.rs diff --git a/Cargo.lock b/Cargo.lock index 6389c5f135..3a322d080e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6164,6 +6164,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "perry-ext-qs" +version = "0.5.1519" +dependencies = [ + "perry-ffi", + "perry-runtime", + "serde_json", +] + [[package]] name = "perry-ext-ratelimit" version = "0.5.1519" diff --git a/Cargo.toml b/Cargo.toml index e890c61a5a..08061d929f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "crates/perry-ext-events", "crates/perry-ext-decimal", "crates/perry-ext-dayjs", + "crates/perry-ext-qs", "crates/perry-ext-moment", "crates/perry-ext-cheerio", "crates/perry-ext-sharp", @@ -491,6 +492,7 @@ perry-ext-axios = { path = "crates/perry-ext-axios" } perry-ext-events = { path = "crates/perry-ext-events" } perry-ext-decimal = { path = "crates/perry-ext-decimal" } perry-ext-dayjs = { path = "crates/perry-ext-dayjs" } +perry-ext-qs = { path = "crates/perry-ext-qs" } perry-ext-moment = { path = "crates/perry-ext-moment" } perry-ext-cheerio = { path = "crates/perry-ext-cheerio" } perry-ext-sharp = { path = "crates/perry-ext-sharp" } diff --git a/changelog.d/8751-native-qs.md b/changelog.d/8751-native-qs.md new file mode 100644 index 0000000000..bce298a0a7 --- /dev/null +++ b/changelog.d/8751-native-qs.md @@ -0,0 +1 @@ +**Native `qs` compatibility:** bundle nested query-string parsing and serialization so Stripe request encoding no longer compiles the AOT-hostile `get-intrinsic` dependency chain. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index c099a601eb..370e208959 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -33,6 +33,7 @@ pub const NATIVE_MODULES: &[&str] = &[ "mysql2/promise", // mysql2's promise-API subpath "pg", // PostgreSQL client "uuid", // RFC-4122 UUID generation + "qs", // nested query-string parser/stringifier (Stripe dependency) "bcrypt", // bcrypt password hashing (replaces the N-API addon) "argon2", // Argon2 password hashing (replaces the N-API addon) "ioredis", // Redis/Valkey client diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index 3894bbfa36..01f196dc7c 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -1114,4 +1114,23 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ property("bun", "stdin"), property("bun", "stdout"), property("bun", "stderr"), + // --- qs (issue #8751) --- + // Native nested query-string codec. This keeps Stripe's request encoder + // off qs' legacy get-intrinsic/ES-shims dependency chain. + method_sig( + "qs", + "stringify", + false, + None, + &[p_any("value"), p_any("options")], + TypeSpec::String, + ), + method_sig( + "qs", + "parse", + false, + None, + &[p_str("input"), p_any("options")], + TypeSpec::Any, + ), ]; diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index cd1a27c466..f9b141831a 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -639,6 +639,7 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[ ("js_node_forge_", "node-forge"), // Native runtime TypeScript transpilation subset (#8511). ("js_typescript_", "typescript"), + ("js_qs_", "qs"), ]; /// Process-wide collector of provider keys observed during codegen. @@ -1172,6 +1173,8 @@ mod tests { ("js_node_forge_create_certificate", "node-forge"), ("js_parcel_watcher_subscribe", "@parcel/watcher"), ("js_parcel_watcher_get_events_since", "@parcel/watcher"), + ("js_qs_stringify", "qs"), + ("js_qs_parse", "qs"), ] { assert_symbol_routes_to(symbol, OwnerKind::WellKnown(binding)); } diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs index 3115da4e5a..65ea9392ce 100644 --- a/crates/perry-codegen/src/lower_call/native_table/mod.rs +++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs @@ -33,6 +33,7 @@ mod node_dns; mod node_domain; mod node_misc; mod parcel_watcher; +mod qs; mod thread_lodash; mod tls_events; mod tui; @@ -178,6 +179,7 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock> = LazyLock::n v.extend_from_slice(media::MEDIA_ROWS); v.extend_from_slice(native_profile::NATIVE_PROFILE_ROWS); v.extend_from_slice(parcel_watcher::PARCEL_WATCHER_ROWS); + v.extend_from_slice(qs::QS_ROWS); v.extend_from_slice(tui::TUI_ROWS); v.extend_from_slice(typescript::TYPESCRIPT_ROWS); v.extend_from_slice(yoga::YOGA_ROWS); diff --git a/crates/perry-codegen/src/lower_call/native_table/qs.rs b/crates/perry-codegen/src/lower_call/native_table/qs.rs new file mode 100644 index 0000000000..a6fefc9562 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/native_table/qs.rs @@ -0,0 +1,22 @@ +use super::*; + +pub(super) const QS_ROWS: &[NativeModSig] = &[ + NativeModSig { + module: "qs", + has_receiver: false, + method: "stringify", + class_filter: None, + runtime: "js_qs_stringify", + args: &[NA_F64, NA_F64], + ret: NR_STR, + }, + NativeModSig { + module: "qs", + has_receiver: false, + method: "parse", + class_filter: None, + runtime: "js_qs_parse", + args: &[NA_STR, NA_F64], + ret: NR_OBJ_FROM_JSON_STR, + }, +]; diff --git a/crates/perry-ext-qs/Cargo.toml b/crates/perry-ext-qs/Cargo.toml new file mode 100644 index 0000000000..96211bb51e --- /dev/null +++ b/crates/perry-ext-qs/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "perry-ext-qs" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Native qs compatibility binding for nested query-string parsing and serialization" + +[lints] +workspace = true + +[lib] +crate-type = ["staticlib", "rlib"] + +[dependencies] +perry-ffi.workspace = true +serde_json.workspace = true + +[dev-dependencies] +perry-ffi = { workspace = true, features = ["runtime-link"] } +perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-qs/src/codec.rs b/crates/perry-ext-qs/src/codec.rs new file mode 100644 index 0000000000..d2458cfd98 --- /dev/null +++ b/crates/perry-ext-qs/src/codec.rs @@ -0,0 +1,128 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Charset { + Utf8, + Latin1, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Format { + Rfc1738, + Rfc3986, +} + +pub(crate) fn encode(input: &str, charset: Charset, format: Format) -> String { + let mut out = String::with_capacity(input.len()); + match charset { + Charset::Utf8 => { + for &byte in input.as_bytes() { + if is_safe(byte, format) { + out.push(byte as char); + } else { + push_escape(&mut out, byte); + } + } + } + Charset::Latin1 => { + for unit in input.encode_utf16() { + if unit <= 0xFF { + let byte = unit as u8; + if is_safe(byte, format) { + out.push(byte as char); + } else { + push_escape(&mut out, byte); + } + } else { + out.push_str("%26%23"); + out.push_str(&unit.to_string()); + out.push_str("%3B"); + } + } + } + } + if format == Format::Rfc1738 { + out = out.replace("%20", "+"); + } + out +} + +pub(crate) fn format_encoded(input: String, format: Format) -> String { + if format == Format::Rfc1738 { + input.replace("%20", "+") + } else { + input + } +} + +pub(crate) fn decode(input: &str, charset: Charset) -> String { + let plus_replaced = input.replace('+', " "); + let mut bytes = Vec::with_capacity(plus_replaced.len()); + let raw = plus_replaced.as_bytes(); + let mut index = 0; + let mut invalid_escape = false; + while index < raw.len() { + if raw[index] == b'%' { + if index + 2 < raw.len() { + if let (Some(high), Some(low)) = (hex(raw[index + 1]), hex(raw[index + 2])) { + bytes.push((high << 4) | low); + index += 3; + continue; + } + } + invalid_escape = true; + } + bytes.push(raw[index]); + index += 1; + } + + match charset { + Charset::Utf8 if invalid_escape => plus_replaced, + Charset::Utf8 => String::from_utf8(bytes).unwrap_or(plus_replaced), + Charset::Latin1 => bytes.into_iter().map(char::from).collect(), + } +} + +fn is_safe(byte: u8, format: Format) -> bool { + byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'.' | b'_' | b'~') + || (format == Format::Rfc1738 && matches!(byte, b'(' | b')')) +} + +fn push_escape(out: &mut String, byte: u8) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + out.push('%'); + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0xF) as usize] as char); +} + +fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc3986_encoding_matches_qs_defaults() { + assert_eq!( + encode("a b[c]/✓", Charset::Utf8, Format::Rfc3986), + "a%20b%5Bc%5D%2F%E2%9C%93" + ); + } + + #[test] + fn rfc1738_uses_plus_and_preserves_parentheses() { + assert_eq!(encode("a b(c)", Charset::Utf8, Format::Rfc1738), "a+b(c)"); + } + + #[test] + fn decoder_is_lenient_like_decode_uri_component_wrapper() { + assert_eq!(decode("a+b%5Bc%5D", Charset::Utf8), "a b[c]"); + assert_eq!(decode("bad%ZZ", Charset::Utf8), "bad%ZZ"); + } +} diff --git a/crates/perry-ext-qs/src/lib.rs b/crates/perry-ext-qs/src/lib.rs new file mode 100644 index 0000000000..7e360fb168 --- /dev/null +++ b/crates/perry-ext-qs/src/lib.rs @@ -0,0 +1,46 @@ +//! Native compatibility binding for [`qs`](https://www.npmjs.com/package/qs). +//! +//! The binding exists primarily so packages such as Stripe can retain qs' +//! nested request encoding without asking Perry's AOT compiler to compile the +//! legacy `get-intrinsic` / ES-shims dependency chain. The implementation is +//! intentionally dependency-light and crosses the runtime only through the +//! stable `perry-ffi` surface plus existing C ABI symbols. + +mod codec; +mod options; +mod parse; +mod runtime; +mod stringify; + +#[cfg(test)] +mod test_async_shims; + +use perry_ffi::{alloc_string, read_string, JsString, StringHeader, TransientRootScope}; + +/// `qs.stringify(value, options?)`. +#[no_mangle] +pub extern "C" fn js_qs_stringify(value: f64, options: f64) -> *mut StringHeader { + alloc_string(&stringify::stringify(value, options)).as_raw() +} + +/// `qs.parse(input, options?)`. +/// +/// # Safety +/// `input` must be null or a live Perry `StringHeader` pointer. +#[no_mangle] +pub unsafe extern "C" fn js_qs_parse( + input: *const StringHeader, + options: f64, +) -> *mut StringHeader { + let input = if input.is_null() { + String::new() + } else { + let input = JsString::from_raw(input as *mut StringHeader); + read_string(input).unwrap_or_default().to_owned() + }; + let scope = TransientRootScope::enter(); + let mut options = options::ParseOptions::from_js(&scope, options); + let value = parse::parse(&input, &mut options); + let json = serde_json::to_string(&value).expect("qs parse tree is JSON serializable"); + alloc_string(&json).as_raw() +} diff --git a/crates/perry-ext-qs/src/options.rs b/crates/perry-ext-qs/src/options.rs new file mode 100644 index 0000000000..648d9b5e42 --- /dev/null +++ b/crates/perry-ext-qs/src/options.rs @@ -0,0 +1,360 @@ +use crate::codec::{Charset, Format}; +use crate::runtime; +use perry_ffi::{ + js_array_get, js_array_length, throw_with_code, ErrorKind, JsValue, TransientRootScope, + TransientRootedNanbox, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ArrayFormat { + Brackets, + Comma, + Indices, + Repeat, +} + +pub(crate) struct StringifyOptions { + pub(crate) add_query_prefix: bool, + pub(crate) allow_dots: bool, + pub(crate) allow_empty_arrays: bool, + pub(crate) array_format: ArrayFormat, + pub(crate) charset: Charset, + pub(crate) charset_sentinel: bool, + pub(crate) comma_round_trip: bool, + pub(crate) delimiter: String, + pub(crate) encode: bool, + pub(crate) encode_dot_in_keys: bool, + pub(crate) encode_values_only: bool, + pub(crate) format: Format, + pub(crate) skip_nulls: bool, + pub(crate) strict_null_handling: bool, + pub(crate) encoder: Option, + pub(crate) filter: Option, + pub(crate) filter_keys: Option>, + pub(crate) serialize_date: Option, + pub(crate) sort: Option, +} + +impl Default for StringifyOptions { + fn default() -> Self { + Self { + add_query_prefix: false, + allow_dots: false, + allow_empty_arrays: false, + array_format: ArrayFormat::Indices, + charset: Charset::Utf8, + charset_sentinel: false, + comma_round_trip: false, + delimiter: "&".to_owned(), + encode: true, + encode_dot_in_keys: false, + encode_values_only: false, + format: Format::Rfc3986, + skip_nulls: false, + strict_null_handling: false, + encoder: None, + filter: None, + filter_keys: None, + serialize_date: None, + sort: None, + } + } +} + +impl StringifyOptions { + pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self { + let mut result = Self::default(); + let value = runtime::from_f64(raw); + if !value.is_pointer() || runtime::is_closure(value) { + return result; + } + let options = scope.root_nanbox(raw); + + validate_bool(scope, &options, "allowEmptyArrays"); + validate_bool(scope, &options, "encodeDotInKeys"); + validate_bool(scope, &options, "commaRoundTrip"); + + result.add_query_prefix = bool_option(scope, &options, "addQueryPrefix", false); + result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false); + result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false); + result.comma_round_trip = bool_option(scope, &options, "commaRoundTrip", false); + result.encode = bool_option(scope, &options, "encode", true); + result.encode_dot_in_keys = bool_option(scope, &options, "encodeDotInKeys", false); + result.encode_values_only = bool_option(scope, &options, "encodeValuesOnly", false); + result.skip_nulls = bool_option(scope, &options, "skipNulls", false); + result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false); + + let allow_dots = field(scope, &options, "allowDots"); + result.allow_dots = if allow_dots.is_undefined() { + result.encode_dot_in_keys + } else { + truthy(allow_dots) + }; + + let delimiter = field(scope, &options, "delimiter"); + if !delimiter.is_undefined() { + result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter)); + } + + let charset = field(scope, &options, "charset"); + if !charset.is_undefined() { + match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() { + Some("utf-8") => result.charset = Charset::Utf8, + Some("iso-8859-1") => result.charset = Charset::Latin1, + _ => { + throw_type("The charset option must be either utf-8, iso-8859-1, or undefined") + } + } + } + + let format = field(scope, &options, "format"); + if !format.is_undefined() { + match runtime::string_value(scope, runtime::as_f64(format)).as_deref() { + Some("RFC1738") => result.format = Format::Rfc1738, + Some("RFC3986") => result.format = Format::Rfc3986, + _ => throw_type("Unknown format option provided."), + } + } + + let array_format = field(scope, &options, "arrayFormat"); + result.array_format = + match runtime::string_value(scope, runtime::as_f64(array_format)).as_deref() { + Some("brackets") => ArrayFormat::Brackets, + Some("comma") => ArrayFormat::Comma, + Some("repeat") => ArrayFormat::Repeat, + Some("indices") => ArrayFormat::Indices, + _ => { + let indices = field(scope, &options, "indices"); + if indices.is_undefined() || truthy(indices) { + ArrayFormat::Indices + } else { + ArrayFormat::Repeat + } + } + }; + + let encoder = field(scope, &options, "encoder"); + if !encoder.is_undefined() && !encoder.is_null() { + if !runtime::is_closure(encoder) { + throw_type("Encoder has to be a function."); + } + result.encoder = Some(scope.root_nanbox(runtime::as_f64(encoder))); + } + + let serialize_date = field(scope, &options, "serializeDate"); + if runtime::is_closure(serialize_date) { + result.serialize_date = Some(scope.root_nanbox(runtime::as_f64(serialize_date))); + } + + let sort = field(scope, &options, "sort"); + if runtime::is_closure(sort) { + result.sort = Some(scope.root_nanbox(runtime::as_f64(sort))); + } + + let filter = field(scope, &options, "filter"); + if runtime::is_closure(filter) { + result.filter = Some(scope.root_nanbox(runtime::as_f64(filter))); + } else if runtime::is_array(runtime::as_f64(filter)) { + let filter = scope.root_nanbox(runtime::as_f64(filter)); + let array = runtime::from_f64(filter.get()).as_pointer(); + let length = unsafe { js_array_length(array) }; + let mut keys = Vec::with_capacity(length as usize); + for index in 0..length { + let array = runtime::from_f64(filter.get()).as_pointer(); + let value = unsafe { js_array_get(array, index) }; + if !value.is_undefined() && !value.is_null() { + keys.push(runtime::owned_string(scope, runtime::as_f64(value))); + } + } + result.filter_keys = Some(keys); + } + + result + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DuplicateMode { + Combine, + First, + Last, +} + +pub(crate) struct ParseOptions { + pub(crate) allow_dots: bool, + pub(crate) allow_empty_arrays: bool, + pub(crate) allow_prototypes: bool, + pub(crate) allow_sparse: bool, + pub(crate) array_limit: usize, + pub(crate) charset: Charset, + pub(crate) charset_sentinel: bool, + pub(crate) comma: bool, + pub(crate) decode_dot_in_keys: bool, + pub(crate) delimiter: String, + pub(crate) depth: usize, + pub(crate) duplicates: DuplicateMode, + pub(crate) ignore_query_prefix: bool, + pub(crate) interpret_numeric_entities: bool, + pub(crate) parameter_limit: usize, + pub(crate) parse_arrays: bool, + pub(crate) strict_depth: bool, + pub(crate) strict_null_handling: bool, + pub(crate) throw_on_limit_exceeded: bool, +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { + allow_dots: false, + allow_empty_arrays: false, + allow_prototypes: false, + allow_sparse: false, + array_limit: 20, + charset: Charset::Utf8, + charset_sentinel: false, + comma: false, + decode_dot_in_keys: false, + delimiter: "&".to_owned(), + depth: 5, + duplicates: DuplicateMode::Combine, + ignore_query_prefix: false, + interpret_numeric_entities: false, + parameter_limit: 1000, + parse_arrays: true, + strict_depth: false, + strict_null_handling: false, + throw_on_limit_exceeded: false, + } + } +} + +impl ParseOptions { + pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self { + let mut result = Self::default(); + let value = runtime::from_f64(raw); + if !value.is_pointer() || runtime::is_closure(value) { + return result; + } + let options = scope.root_nanbox(raw); + + result.allow_dots = bool_option(scope, &options, "allowDots", false); + result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false); + result.allow_prototypes = bool_option(scope, &options, "allowPrototypes", false); + result.allow_sparse = bool_option(scope, &options, "allowSparse", false); + result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false); + result.comma = bool_option(scope, &options, "comma", false); + result.decode_dot_in_keys = bool_option(scope, &options, "decodeDotInKeys", false); + result.ignore_query_prefix = bool_option(scope, &options, "ignoreQueryPrefix", false); + result.interpret_numeric_entities = + bool_option(scope, &options, "interpretNumericEntities", false); + result.parse_arrays = bool_option(scope, &options, "parseArrays", true); + result.strict_depth = bool_option(scope, &options, "strictDepth", false); + result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false); + result.throw_on_limit_exceeded = + bool_option(scope, &options, "throwOnLimitExceeded", false); + + result.array_limit = number_option(scope, &options, "arrayLimit", 20); + result.depth = number_option(scope, &options, "depth", 5); + result.parameter_limit = number_option(scope, &options, "parameterLimit", 1000); + + let delimiter = field(scope, &options, "delimiter"); + if !delimiter.is_undefined() { + if delimiter.is_pointer() && !delimiter.is_any_string() { + throw_type("Regular-expression delimiters are not supported by the native qs shim"); + } + result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter)); + } + + let charset = field(scope, &options, "charset"); + if !charset.is_undefined() { + match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() { + Some("utf-8") => result.charset = Charset::Utf8, + Some("iso-8859-1") => result.charset = Charset::Latin1, + _ => { + throw_type("The charset option must be either utf-8, iso-8859-1, or undefined") + } + } + } + + let duplicates = field(scope, &options, "duplicates"); + if !duplicates.is_undefined() { + result.duplicates = + match runtime::string_value(scope, runtime::as_f64(duplicates)).as_deref() { + Some("combine") => DuplicateMode::Combine, + Some("first") => DuplicateMode::First, + Some("last") => DuplicateMode::Last, + _ => throw_type("The duplicates option must be either combine, first, or last"), + }; + } + + let decoder = field(scope, &options, "decoder"); + if !decoder.is_undefined() && !decoder.is_null() { + if !runtime::is_closure(decoder) { + throw_type("Decoder has to be a function."); + } + throw_type("Custom decoders are not supported by the native qs shim"); + } + + result + } +} + +fn field(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) -> JsValue { + runtime::field_by_name(scope, options, name) +} + +fn bool_option( + scope: &TransientRootScope, + options: &TransientRootedNanbox, + name: &str, + default: bool, +) -> bool { + let value = field(scope, options, name); + if value.is_bool() { + value.to_bool() + } else { + default + } +} + +fn validate_bool(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) { + let value = field(scope, options, name); + if !value.is_undefined() && !value.is_bool() { + throw_type(&format!( + "`{name}` option can only be `true` or `false`, when provided" + )); + } +} + +fn number_option( + scope: &TransientRootScope, + options: &TransientRootedNanbox, + name: &str, + default: usize, +) -> usize { + let value = field(scope, options, name); + if value.is_number() { + let value = value.to_number(); + if value.is_finite() && value >= 0.0 { + return value.floor() as usize; + } + } + default +} + +fn truthy(value: JsValue) -> bool { + if value.is_undefined() || value.is_null() { + false + } else if value.is_bool() { + value.to_bool() + } else if value.is_number() { + let number = value.to_number(); + number != 0.0 && !number.is_nan() + } else { + true + } +} + +fn throw_type(message: &str) -> ! { + throw_with_code(message, "", ErrorKind::TypeError) +} diff --git a/crates/perry-ext-qs/src/parse.rs b/crates/perry-ext-qs/src/parse.rs new file mode 100644 index 0000000000..c22dcee8ee --- /dev/null +++ b/crates/perry-ext-qs/src/parse.rs @@ -0,0 +1,449 @@ +use crate::codec::{self, Charset}; +use crate::options::{DuplicateMode, ParseOptions}; +use perry_ffi::{throw_with_code, ErrorKind}; +use serde_json::{Map, Value}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Segment { + Key(String), + Index(usize), + Append, +} + +pub(crate) fn parse(input: &str, options: &mut ParseOptions) -> Value { + let input = if options.ignore_query_prefix { + input.strip_prefix('?').unwrap_or(input) + } else { + input + }; + if input.is_empty() { + return Value::Object(Map::new()); + } + + let mut pairs: Vec<&str> = if options.delimiter.is_empty() { + vec![input] + } else { + input.split(&options.delimiter).collect() + }; + if pairs.len() > options.parameter_limit { + if options.throw_on_limit_exceeded { + throw_with_code( + &format!( + "Parameter limit exceeded. Only {} parameter{} allowed.", + options.parameter_limit, + if options.parameter_limit == 1 { + " is" + } else { + "s are" + } + ), + "", + ErrorKind::RangeError, + ); + } + pairs.truncate(options.parameter_limit); + } + + if options.charset_sentinel { + if let Some((index, charset)) = pairs.iter().enumerate().find_map(|(index, pair)| { + if *pair == "utf8=%E2%9C%93" { + Some((index, Charset::Utf8)) + } else if *pair == "utf8=%26%2310003%3B" { + Some((index, Charset::Latin1)) + } else { + None + } + }) { + options.charset = charset; + pairs.remove(index); + } + } + + let mut root = Value::Object(Map::new()); + for pair in pairs { + let (raw_key, raw_value, had_equals) = match pair.find('=') { + Some(index) => (&pair[..index], &pair[index + 1..], true), + None => (pair, "", false), + }; + let mut key = codec::decode(raw_key, options.charset); + if options.decode_dot_in_keys { + key = key.replace("%2E", ".").replace("%2e", "."); + } + let mut value = codec::decode(raw_value, options.charset); + if options.charset == Charset::Latin1 && options.interpret_numeric_entities { + value = decode_numeric_entities(&value); + } + + let mut segments = parse_segments(&key, options); + if segments.is_empty() || forbidden_path(&segments, options.allow_prototypes) { + continue; + } + + let empty_array = !had_equals + && options.allow_empty_arrays + && matches!(segments.last(), Some(Segment::Append)); + let parsed_value = if empty_array { + segments.pop(); + Value::Array(Vec::new()) + } else if !had_equals && options.strict_null_handling { + Value::Null + } else if options.comma && value.contains(',') { + Value::Array( + value + .split(',') + .map(|part| Value::String(part.to_owned())) + .collect(), + ) + } else { + Value::String(value) + }; + insert(&mut root, &segments, parsed_value, options); + } + root +} + +fn parse_segments(key: &str, options: &ParseOptions) -> Vec { + let use_dots = options.allow_dots || options.decode_dot_in_keys; + let mut raw_segments = Vec::new(); + let mut index = key + .char_indices() + .find_map(|(index, ch)| (ch == '[' || (use_dots && ch == '.')).then_some(index)) + .unwrap_or(key.len()); + raw_segments.push(key[..index].to_owned()); + let mut nested = 0usize; + + while index < key.len() { + match key.as_bytes()[index] { + b'.' if use_dots => { + let start = index + 1; + let end = key[start..] + .char_indices() + .find_map(|(offset, ch)| { + (ch == '[' || (use_dots && ch == '.')).then_some(start + offset) + }) + .unwrap_or(key.len()); + raw_segments.push(key[start..end].to_owned()); + index = end; + } + b'[' => { + let bracket_start = index; + let Some(close_offset) = key[index + 1..].find(']') else { + raw_segments.push(key[index..].to_owned()); + break; + }; + let close = index + 1 + close_offset; + nested += 1; + if nested > options.depth { + if options.strict_depth { + throw_with_code( + &format!( + "Input depth exceeded depth option of {} and strictDepth is true", + options.depth + ), + "", + ErrorKind::RangeError, + ); + } + raw_segments.push(key[bracket_start..].to_owned()); + index = key.len(); + continue; + } + raw_segments.push(key[index + 1..close].to_owned()); + index = close + 1; + } + _ => { + raw_segments.push(key[index..].to_owned()); + break; + } + } + } + + raw_segments + .into_iter() + .enumerate() + .map(|(position, segment)| { + if position > 0 && segment.is_empty() && options.parse_arrays { + Segment::Append + } else if position > 0 && options.parse_arrays { + match segment.parse::() { + Ok(index) if index <= options.array_limit => Segment::Index(index), + _ => Segment::Key(segment), + } + } else { + Segment::Key(segment) + } + }) + .collect() +} + +fn forbidden_path(segments: &[Segment], allow_prototypes: bool) -> bool { + const OBJECT_PROTOTYPE_KEYS: &[&str] = &[ + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", + ]; + segments.iter().any(|segment| match segment { + Segment::Key(key) if key == "__proto__" => true, + Segment::Key(key) if !allow_prototypes => OBJECT_PROTOTYPE_KEYS.contains(&key.as_str()), + _ => false, + }) +} + +fn insert(node: &mut Value, segments: &[Segment], value: Value, options: &ParseOptions) { + let Some((segment, rest)) = segments.split_first() else { + merge_leaf(node, value, options.duplicates); + return; + }; + + match segment { + Segment::Key(key) => { + if !node.is_object() { + *node = Value::Object(Map::new()); + } + let object = node.as_object_mut().expect("object initialized"); + if rest.is_empty() { + match object.get_mut(key) { + Some(existing) => merge_leaf(existing, value, options.duplicates), + None => { + object.insert(key.clone(), value); + } + } + return; + } + let child = object + .entry(key.clone()) + .or_insert_with(|| empty_container(&rest[0], options)); + insert(child, rest, value, options); + } + Segment::Index(requested) => { + if !node.is_array() { + *node = Value::Array(Vec::new()); + } + let array = node.as_array_mut().expect("array initialized"); + let position = if options.allow_sparse { + while array.len() <= *requested { + array.push(Value::Null); + } + *requested + } else if *requested < array.len() { + *requested + } else { + if rest.is_empty() { + array.push(value); + return; + } + array.push(empty_container(&rest[0], options)); + let position = array.len() - 1; + insert(&mut array[position], rest, value, options); + return; + }; + if rest.is_empty() { + if options.allow_sparse && array[position].is_null() { + array[position] = value; + } else { + merge_leaf(&mut array[position], value, options.duplicates); + } + } else { + if array[position].is_null() { + array[position] = empty_container(&rest[0], options); + } + insert(&mut array[position], rest, value, options); + } + } + Segment::Append => { + if !node.is_array() { + *node = Value::Array(Vec::new()); + } + let array = node.as_array_mut().expect("array initialized"); + if rest.is_empty() { + array.push(value); + } else { + let mut child = empty_container(&rest[0], options); + insert(&mut child, rest, value, options); + array.push(child); + } + } + } +} + +fn empty_container(next: &Segment, options: &ParseOptions) -> Value { + if options.parse_arrays && matches!(next, Segment::Index(_) | Segment::Append) { + Value::Array(Vec::new()) + } else { + Value::Object(Map::new()) + } +} + +fn merge_leaf(existing: &mut Value, value: Value, mode: DuplicateMode) { + match mode { + DuplicateMode::First => {} + DuplicateMode::Last => *existing = value, + DuplicateMode::Combine => match existing { + Value::Array(values) => values.push(value), + _ => { + let previous = std::mem::replace(existing, Value::Null); + *existing = Value::Array(vec![previous, value]); + } + }, + } +} + +fn decode_numeric_entities(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("&#") { + output.push_str(&rest[..start]); + let entity = &rest[start + 2..]; + let Some(end) = entity.find(';') else { + output.push_str(&rest[start..]); + return output; + }; + let digits = &entity[..end]; + if let Ok(codepoint) = digits.parse::() { + if let Some(ch) = char::from_u32(codepoint) { + output.push(ch); + } else { + output.push_str(&rest[start..start + end + 3]); + } + } else { + output.push_str(&rest[start..start + end + 3]); + } + rest = &entity[end + 1..]; + } + output.push_str(rest); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(input: &str) -> Value { + parse(input, &mut ParseOptions::default()) + } + + #[test] + fn parses_nested_objects_arrays_and_duplicates() { + assert_eq!( + parsed("customer[name]=Ada&items[0][id]=price_1&items[1][id]=price_2&tag=a&tag=b"), + serde_json::json!({ + "customer": { "name": "Ada" }, + "items": [{ "id": "price_1" }, { "id": "price_2" }], + "tag": ["a", "b"] + }) + ); + } + + #[test] + fn blocks_prototype_pollution_segments() { + assert_eq!( + parsed("safe=yes&__proto__[polluted]=yes&constructor[prototype][bad]=yes"), + serde_json::json!({ "safe": "yes" }) + ); + } + + #[test] + fn array_limit_falls_back_to_object_key() { + assert_eq!(parsed("a[21]=x"), serde_json::json!({ "a": { "21": "x" } })); + } + + #[test] + fn supports_dot_and_sparse_modes() { + let mut options = ParseOptions { + allow_dots: true, + allow_sparse: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("a.b[2]=x", &mut options), + serde_json::json!({ "a": { "b": [null, null, "x"] } }) + ); + } + + #[test] + fn allow_empty_arrays_matches_qs_without_strict_null_mode() { + let mut options = ParseOptions { + allow_empty_arrays: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("foo[]", &mut options), + serde_json::json!({ "foo": [] }) + ); + } + + #[test] + fn duplicates_modes_match_qs() { + let mut first = ParseOptions { + duplicates: DuplicateMode::First, + ..ParseOptions::default() + }; + let mut last = ParseOptions { + duplicates: DuplicateMode::Last, + ..ParseOptions::default() + }; + assert_eq!( + parse("a=b&a=c", &mut first), + serde_json::json!({ "a": "b" }) + ); + assert_eq!(parse("a=b&a=c", &mut last), serde_json::json!({ "a": "c" })); + } + + #[test] + fn query_prefix_comma_and_strict_null_options_match_qs() { + let mut comma = ParseOptions { + ignore_query_prefix: true, + comma: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("?a=b,c", &mut comma), + serde_json::json!({ "a": ["b", "c"] }) + ); + + let mut strict = ParseOptions { + strict_null_handling: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("a&b=", &mut strict), + serde_json::json!({ "a": null, "b": "" }) + ); + } + + #[test] + fn charset_sentinel_depth_and_encoded_dots_match_qs() { + let mut charset = ParseOptions { + charset_sentinel: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("utf8=%26%2310003%3B&a=%F8", &mut charset), + serde_json::json!({ "a": "ø" }) + ); + + assert_eq!( + parsed("a[b][c][d][e][f][g]=h"), + serde_json::json!({ + "a": { "b": { "c": { "d": { "e": { "f": { "[g]": "h" } } } } } } + }) + ); + + let mut dots = ParseOptions { + decode_dot_in_keys: true, + ..ParseOptions::default() + }; + assert_eq!( + parse("a%2Eb=c", &mut dots), + serde_json::json!({ "a": { "b": "c" } }) + ); + } +} diff --git a/crates/perry-ext-qs/src/runtime.rs b/crates/perry-ext-qs/src/runtime.rs new file mode 100644 index 0000000000..8e46266fc7 --- /dev/null +++ b/crates/perry-ext-qs/src/runtime.rs @@ -0,0 +1,134 @@ +use perry_ffi::{ + alloc_string, read_string, ArrayHeader, ClosureHeader, JsClosure, JsString, JsValue, + ObjectHeader, StringHeader, TransientRootScope, TransientRootedNanbox, +}; + +extern "C" { + fn js_array_is_array(value: f64) -> f64; + fn js_date_to_iso_string_or_throw(value: f64) -> *mut StringHeader; + fn js_get_string_pointer_unified(value: f64) -> i64; + fn js_jsvalue_to_string(value: f64) -> *mut StringHeader; + fn js_object_get_field_by_name( + object: *const ObjectHeader, + key: *const StringHeader, + ) -> JsValue; + fn js_object_keys_value(value: f64) -> *mut ArrayHeader; + fn js_util_types_is_date(value: f64) -> f64; + fn js_value_is_closure(value_bits: i64) -> i32; +} + +#[inline] +pub(crate) fn as_f64(value: JsValue) -> f64 { + f64::from_bits(value.bits()) +} + +#[inline] +pub(crate) fn from_f64(value: f64) -> JsValue { + JsValue::from_bits(value.to_bits()) +} + +pub(crate) fn is_array(value: f64) -> bool { + from_f64(unsafe { js_array_is_array(value) }).to_bool() +} + +pub(crate) fn is_date(value: f64) -> bool { + from_f64(unsafe { js_util_types_is_date(value) }).to_bool() +} + +pub(crate) fn is_closure(value: JsValue) -> bool { + unsafe { js_value_is_closure(value.bits() as i64) != 0 } +} + +pub(crate) fn owned_string(scope: &TransientRootScope, value: f64) -> String { + let rooted = scope.root_nanbox(value); + let ptr = unsafe { js_jsvalue_to_string(rooted.get()) }; + read_owned_header(ptr) +} + +pub(crate) fn string_value(scope: &TransientRootScope, value: f64) -> Option { + let rooted = scope.root_nanbox(value); + let js = from_f64(rooted.get()); + if !js.is_any_string() { + return None; + } + let ptr = unsafe { js_get_string_pointer_unified(rooted.get()) } as *mut StringHeader; + Some(read_owned_header(ptr)) +} + +pub(crate) fn date_iso(scope: &TransientRootScope, value: f64) -> String { + let rooted = scope.root_nanbox(value); + let ptr = unsafe { js_date_to_iso_string_or_throw(rooted.get()) }; + read_owned_header(ptr) +} + +pub(crate) fn object_keys( + scope: &TransientRootScope, + value: &TransientRootedNanbox, +) -> TransientRootedNanbox { + let keys = unsafe { js_object_keys_value(value.get()) }; + let boxed = JsValue::from_object_ptr(keys); + scope.root_nanbox(as_f64(boxed)) +} + +pub(crate) fn field_by_name( + scope: &TransientRootScope, + object: &TransientRootedNanbox, + name: &str, +) -> JsValue { + let key = JsValue::from_string_ptr(alloc_string(name).as_raw()); + let key = scope.root_nanbox(as_f64(key)); + field_by_key(object, &key) +} + +pub(crate) fn field_by_key(object: &TransientRootedNanbox, key: &TransientRootedNanbox) -> JsValue { + let key_value = from_f64(key.get()); + let key = if key_value.is_string() { + key_value.as_string_ptr() + } else { + (unsafe { js_get_string_pointer_unified(key.get()) }) as *mut StringHeader + }; + // Materializing an SSO key may allocate and move the object. Reload the + // rooted object only after the key is a stable heap string. + let object = from_f64(object.get()).as_pointer::(); + if object.is_null() || key.is_null() { + JsValue::UNDEFINED + } else { + unsafe { js_object_get_field_by_name(object, key) } + } +} + +pub(crate) fn call1(scope: &TransientRootScope, callback: &TransientRootedNanbox, arg: f64) -> f64 { + let arg = scope.root_nanbox(arg); + let callback_value = from_f64(callback.get()); + let closure = unsafe { + JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader) + }; + unsafe { closure.call1(arg.get()) } +} + +pub(crate) fn call2( + scope: &TransientRootScope, + callback: &TransientRootedNanbox, + arg0: f64, + arg1: f64, +) -> f64 { + let arg0 = scope.root_nanbox(arg0); + let arg1 = scope.root_nanbox(arg1); + let callback_value = from_f64(callback.get()); + let closure = unsafe { + JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader) + }; + unsafe { closure.call2(arg0.get(), arg1.get()) } +} + +pub(crate) fn alloc_string_value(value: &str) -> f64 { + as_f64(JsValue::from_string_ptr(alloc_string(value).as_raw())) +} + +fn read_owned_header(ptr: *mut StringHeader) -> String { + if ptr.is_null() { + return String::new(); + } + let string = unsafe { JsString::from_raw(ptr) }; + read_string(string).unwrap_or_default().to_owned() +} diff --git a/crates/perry-ext-qs/src/stringify.rs b/crates/perry-ext-qs/src/stringify.rs new file mode 100644 index 0000000000..4e62fcf65d --- /dev/null +++ b/crates/perry-ext-qs/src/stringify.rs @@ -0,0 +1,330 @@ +use crate::codec; +use crate::options::{ArrayFormat, StringifyOptions}; +use crate::runtime; +use perry_ffi::{ + js_array_get, js_array_length, throw_with_code, value_byte_slice, ErrorKind, + TransientRootScope, TransientRootedNanbox, +}; +use std::cmp::Ordering; + +pub(crate) fn stringify(value: f64, options: f64) -> String { + let scope = TransientRootScope::enter(); + let options = StringifyOptions::from_js(&scope, options); + let mut root = scope.root_nanbox(value); + + if let Some(filter) = &options.filter { + root = apply_filter(&scope, filter, "", root.get()); + } + + let root_value = runtime::from_f64(root.get()); + if !root_value.is_pointer() || root_value.is_null() || runtime::is_closure(root_value) { + return String::new(); + } + + let mut keys = options + .filter_keys + .clone() + .unwrap_or_else(|| own_keys(&scope, &root)); + sort_keys(&scope, &options, &mut keys); + + let mut values = Vec::new(); + let mut ancestors = vec![root]; + for key in keys { + let value = runtime::field_by_name(&scope, &root, &key); + if options.skip_nulls && value.is_null() { + continue; + } + values.extend(stringify_value( + &scope, + &options, + runtime::as_f64(value), + key, + &mut ancestors, + )); + } + + let joined = values.join(&options.delimiter); + if joined.is_empty() { + return joined; + } + + let mut prefix = String::new(); + if options.add_query_prefix { + prefix.push('?'); + } + if options.charset_sentinel { + match options.charset { + codec::Charset::Utf8 => prefix.push_str("utf8=%E2%9C%93"), + codec::Charset::Latin1 => prefix.push_str("utf8=%26%2310003%3B"), + } + prefix.push_str(&options.delimiter); + } + prefix + joined.as_str() +} + +fn stringify_value( + scope: &TransientRootScope, + options: &StringifyOptions, + raw: f64, + mut prefix: String, + ancestors: &mut Vec, +) -> Vec { + let mut value = scope.root_nanbox(raw); + + if let Some(filter) = &options.filter { + value = apply_filter(scope, filter, &prefix, value.get()); + } else if runtime::is_date(value.get()) { + value = if let Some(callback) = &options.serialize_date { + scope.root_nanbox(runtime::call1(scope, callback, value.get())) + } else { + let iso = runtime::date_iso(scope, value.get()); + scope.root_nanbox(runtime::alloc_string_value(&iso)) + }; + } + + let js = runtime::from_f64(value.get()); + if js.is_null() { + if options.strict_null_handling { + return vec![encode_key(scope, options, &prefix)]; + } + value = scope.root_nanbox(runtime::alloc_string_value("")); + } + + let js = runtime::from_f64(value.get()); + if js.is_undefined() || runtime::is_closure(js) { + return Vec::new(); + } + + if let Some(bytes) = value_byte_slice(js) { + let text = String::from_utf8_lossy(bytes).into_owned(); + return vec![format!( + "{}={}", + encode_key(scope, options, &prefix), + encode_text(scope, options, &text, false) + )]; + } + + if !js.is_pointer() { + return vec![format!( + "{}={}", + encode_key(scope, options, &prefix), + encode_value(scope, options, value.get()) + )]; + } + + let is_array = runtime::is_array(value.get()); + if is_array && options.array_format == ArrayFormat::Comma { + return stringify_comma_array(scope, options, &value, prefix); + } + + if ancestors + .iter() + .any(|ancestor| same_heap_value(ancestor.get(), value.get())) + { + throw_with_code("Cyclic object value", "", ErrorKind::RangeError); + } + ancestors.push(value); + + let mut keys = options + .filter_keys + .clone() + .unwrap_or_else(|| own_keys(scope, &value)); + sort_keys(scope, options, &mut keys); + + if options.encode_dot_in_keys { + prefix = prefix.replace('.', "%2E"); + } + let adjusted_prefix = if is_array + && options.array_format == ArrayFormat::Comma + && options.comma_round_trip + && keys.len() == 1 + { + format!("{prefix}[]") + } else { + prefix + }; + + if options.allow_empty_arrays && is_array && keys.is_empty() { + ancestors.pop(); + return vec![format!("{adjusted_prefix}[]")]; + } + + let mut values = Vec::new(); + for key in keys { + let child = runtime::field_by_name(scope, &value, &key); + if options.skip_nulls && child.is_null() { + continue; + } + let key = if options.allow_dots && options.encode_dot_in_keys { + key.replace('.', "%2E") + } else { + key + }; + let child_prefix = if is_array { + match options.array_format { + ArrayFormat::Brackets => format!("{adjusted_prefix}[]"), + ArrayFormat::Indices => format!("{adjusted_prefix}[{key}]"), + ArrayFormat::Repeat => adjusted_prefix.clone(), + ArrayFormat::Comma => unreachable!(), + } + } else if options.allow_dots { + format!("{adjusted_prefix}.{key}") + } else { + format!("{adjusted_prefix}[{key}]") + }; + values.extend(stringify_value( + scope, + options, + runtime::as_f64(child), + child_prefix, + ancestors, + )); + } + ancestors.pop(); + values +} + +fn stringify_comma_array( + scope: &TransientRootScope, + options: &StringifyOptions, + value: &TransientRootedNanbox, + mut prefix: String, +) -> Vec { + let array = runtime::from_f64(value.get()).as_pointer(); + let length = unsafe { js_array_length(array) }; + if length == 0 { + return if options.allow_empty_arrays { + vec![format!("{prefix}[]")] + } else { + Vec::new() + }; + } + + if options.comma_round_trip && length == 1 { + prefix.push_str("[]"); + } + let mut parts = Vec::with_capacity(length as usize); + for index in 0..length { + let array = runtime::from_f64(value.get()).as_pointer(); + let mut item = unsafe { js_array_get(array, index) }; + if runtime::is_date(runtime::as_f64(item)) { + item = if let Some(callback) = &options.serialize_date { + runtime::from_f64(runtime::call1(scope, callback, runtime::as_f64(item))) + } else { + let iso = runtime::date_iso(scope, runtime::as_f64(item)); + runtime::from_f64(runtime::alloc_string_value(&iso)) + }; + } + if item.is_null() || item.is_undefined() { + parts.push(String::new()); + } else { + let text = runtime::owned_string(scope, runtime::as_f64(item)); + parts.push(if options.encode_values_only && options.encode { + encode_text(scope, options, &text, false) + } else { + text + }); + } + } + let joined = parts.join(","); + if joined.is_empty() && options.strict_null_handling { + vec![encode_key(scope, options, &prefix)] + } else { + let encoded_value = if options.encode_values_only && options.encode { + codec::format_encoded(joined, options.format) + } else { + encode_text(scope, options, &joined, false) + }; + vec![format!( + "{}={}", + encode_key(scope, options, &prefix), + encoded_value + )] + } +} + +fn own_keys(scope: &TransientRootScope, value: &TransientRootedNanbox) -> Vec { + let keys = runtime::object_keys(scope, value); + let array = runtime::from_f64(keys.get()).as_pointer(); + let length = unsafe { js_array_length(array) }; + let mut result = Vec::with_capacity(length as usize); + for index in 0..length { + let array = runtime::from_f64(keys.get()).as_pointer(); + let key = unsafe { js_array_get(array, index) }; + result.push(runtime::owned_string(scope, runtime::as_f64(key))); + } + result +} + +fn sort_keys(scope: &TransientRootScope, options: &StringifyOptions, keys: &mut [String]) { + let Some(callback) = &options.sort else { + return; + }; + keys.sort_by(|left, right| { + let left = scope.root_nanbox(runtime::alloc_string_value(left)); + let right = scope.root_nanbox(runtime::alloc_string_value(right)); + let result = runtime::from_f64(runtime::call2(scope, callback, left.get(), right.get())); + let number = result.to_number(); + if number < 0.0 { + Ordering::Less + } else if number > 0.0 { + Ordering::Greater + } else { + Ordering::Equal + } + }); +} + +fn apply_filter( + scope: &TransientRootScope, + callback: &TransientRootedNanbox, + prefix: &str, + value: f64, +) -> TransientRootedNanbox { + let value = scope.root_nanbox(value); + let prefix = scope.root_nanbox(runtime::alloc_string_value(prefix)); + scope.root_nanbox(runtime::call2(scope, callback, prefix.get(), value.get())) +} + +fn encode_key(scope: &TransientRootScope, options: &StringifyOptions, key: &str) -> String { + if options.encode_values_only { + codec::format_encoded(key.to_owned(), options.format) + } else { + encode_text(scope, options, key, true) + } +} + +fn encode_value(scope: &TransientRootScope, options: &StringifyOptions, value: f64) -> String { + if !options.encode { + return codec::format_encoded(runtime::owned_string(scope, value), options.format); + } + if let Some(callback) = &options.encoder { + let encoded = runtime::call1(scope, callback, value); + return codec::format_encoded(runtime::owned_string(scope, encoded), options.format); + } + let text = runtime::owned_string(scope, value); + codec::encode(&text, options.charset, options.format) +} + +fn encode_text( + scope: &TransientRootScope, + options: &StringifyOptions, + text: &str, + _is_key: bool, +) -> String { + if !options.encode { + return codec::format_encoded(text.to_owned(), options.format); + } + if let Some(callback) = &options.encoder { + let value = runtime::alloc_string_value(text); + let encoded = runtime::call1(scope, callback, value); + return codec::format_encoded(runtime::owned_string(scope, encoded), options.format); + } + codec::encode(text, options.charset, options.format) +} + +fn same_heap_value(left: f64, right: f64) -> bool { + let left = runtime::from_f64(left); + let right = runtime::from_f64(right); + left.is_pointer() && right.is_pointer() && left.as_pointer::() == right.as_pointer::() +} diff --git a/crates/perry-ext-qs/src/test_async_shims.rs b/crates/perry-ext-qs/src/test_async_shims.rs new file mode 100644 index 0000000000..30431722a4 --- /dev/null +++ b/crates/perry-ext-qs/src/test_async_shims.rs @@ -0,0 +1,113 @@ +//! Test-only host shims for the standalone extension test binary. + +use perry_ffi::{NativeAsyncCompletion, Promise}; +use std::ffi::c_void; + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_new() -> *mut Promise { + perry_runtime::promise::js_promise_new() as *mut Promise +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_resolve_bits(promise: *mut Promise, bits: u64) { + perry_runtime::promise::js_promise_resolve( + promise as *mut perry_runtime::Promise, + f64::from_bits(bits), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64) { + perry_runtime::promise::js_promise_reject( + promise as *mut perry_runtime::Promise, + f64::from_bits(bits), + ); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_resolve_deferred( + promise: *mut Promise, + context: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_resolve_bits(promise, invoke(context)); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_promise_reject_deferred( + promise: *mut Promise, + context: *mut c_void, + invoke: extern "C" fn(*mut c_void) -> u64, +) { + perry_ffi_promise_reject_bits(promise, invoke(context)); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_spawn_blocking( + context: *mut c_void, + invoke: extern "C" fn(*mut c_void), +) { + invoke(context); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_spawn_blocking_with_reactor( + context: *mut c_void, + invoke: extern "C" fn(*mut c_void), +) { + invoke(context); +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_new(_flags: u32) -> *mut NativeAsyncCompletion { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_promise( + _token: *mut NativeAsyncCompletion, +) -> *mut Promise { + std::ptr::null_mut() +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_resolve_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_bits( + _token: *mut NativeAsyncCompletion, + _bits: u64, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_reject_string( + _token: *mut NativeAsyncCompletion, + _data: *const u8, + _len: usize, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_cancel(_token: *mut NativeAsyncCompletion) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_native_async_attach_handle( + _token: *mut NativeAsyncCompletion, + _handle_bits: u64, + _cleanup_flags: u32, +) -> i32 { + 0 +} + +#[no_mangle] +pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} diff --git a/crates/perry/src/commands/compile/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index 95ced11120..573723d2e9 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -473,7 +473,7 @@ mod tests { /// stay `Partial` and are never silently treated as complete drop-ins. #[test] fn shipped_subset_bindings_are_partial() { - for name in ["undici", "node-forge", "lru-cache"] { + for name in ["undici", "node-forge", "lru-cache", "qs"] { let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); assert_eq!( b.compat, diff --git a/crates/perry/tests/issue_8751_qs_native_shim.rs b/crates/perry/tests/issue_8751_qs_native_shim.rs new file mode 100644 index 0000000000..7e3cf96599 --- /dev/null +++ b/crates/perry/tests/issue_8751_qs_native_shim.rs @@ -0,0 +1,154 @@ +//! Regression coverage for #8751: Stripe's CommonJS request helper requires +//! `qs` and calls `qs.stringify` with indexed arrays and a Date serializer. +//! Compiling upstream qs pulls in get-intrinsic's legacy ES-shims chain, which +//! is hostile to Perry's AOT path. The bundled binding must win even when a +//! deliberately broken on-disk qs package is present transitively. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn stripe_style_dependency_uses_native_qs_without_compiling_installed_source() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "issue-8751", + "type": "module", + "perry": { + "compilePackages": ["stripe-fixture"], + "allow": { "compilePackages": ["stripe-fixture"] } + } +}"#, + ) + .expect("write app package.json"); + + let stripe = root.join("node_modules").join("stripe-fixture"); + std::fs::create_dir_all(&stripe).expect("mkdir stripe fixture"); + std::fs::write( + stripe.join("package.json"), + r#"{ "name": "stripe-fixture", "version": "1.0.0", "main": "index.js" }"#, + ) + .expect("write stripe fixture package.json"); + std::fs::write( + stripe.join("index.js"), + r#"'use strict'; +const qs = require('qs'); + +exports.encodeStripePayload = function encodeStripePayload(data) { + return qs.stringify(data, { + serializeDate: function serializeDate(date) { + return Math.floor(date.getTime() / 1000).toString(); + }, + arrayFormat: 'indices' + }).replace(/%5B/g, '[').replace(/%5D/g, ']'); +}; +"#, + ) + .expect("write stripe fixture source"); + + // If module resolution ever falls back to compiling the installed qs, + // compilation or startup fails with this sentinel. A get-intrinsic stub is + // included to retain the transitive shape reported in #8751. + let qs = root.join("node_modules").join("qs"); + std::fs::create_dir_all(&qs).expect("mkdir hostile qs"); + std::fs::write( + qs.join("package.json"), + r#"{ "name": "qs", "version": "6.15.3", "main": "index.js" }"#, + ) + .expect("write hostile qs package.json"); + std::fs::write( + qs.join("index.js"), + "throw new Error('AOT-HOSTILE-QS-SOURCE-WAS-COMPILED');\n", + ) + .expect("write hostile qs source"); + let intrinsic = root.join("node_modules").join("get-intrinsic"); + std::fs::create_dir_all(&intrinsic).expect("mkdir get-intrinsic"); + std::fs::write( + intrinsic.join("package.json"), + r#"{ "name": "get-intrinsic", "version": "1.3.0", "main": "index.js" }"#, + ) + .expect("write get-intrinsic package.json"); + std::fs::write( + intrinsic.join("index.js"), + "throw new SyntaxError('intrinsic %% does not exist!');\n", + ) + .expect("write get-intrinsic source"); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import qsDefault from "qs"; +import * as qsNamespace from "qs"; +import { parse, stringify } from "qs"; +import { encodeStripePayload } from "stripe-fixture"; + +const payload = { + customer: { name: "Ada Lovelace" }, + items: [ + { price: "p_1", quantity: 2 }, + { price: "p_2", quantity: 1 } + ], + metadata: { empty: null }, + created: new Date("2024-01-02T03:04:05Z") +}; + +console.log(encodeStripePayload(payload)); +console.log(JSON.stringify(parse("customer[name]=Ada%20Lovelace&items[0][price]=p_1&items[1][price]=p_2&tag=a&tag=b"))); +console.log(stringify({ a: ["x", "y"], empty: [], nil: null }, { + arrayFormat: "brackets", + allowEmptyArrays: true, + strictNullHandling: true, + addQueryPrefix: true +})); +console.log(stringify({ z: "last", a: "first" }, { + sort: (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0, + encoder: (value: any) => "X" + String(value) +})); +console.log(qsNamespace.stringify({ a: 1 }), qsDefault.stringify({ a: 1 }), stringify({ a: 1 })); +"#, + ) + .expect("write entry"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .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).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) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + concat!( + "customer[name]=Ada%20Lovelace&items[0][price]=p_1&items[0][quantity]=2&items[1][price]=p_2&items[1][quantity]=1&metadata[empty]=&created=1704164645\n", + "{\"customer\":{\"name\":\"Ada Lovelace\"},\"items\":[{\"price\":\"p_1\"},{\"price\":\"p_2\"}],\"tag\":[\"a\",\"b\"]}\n", + "?a%5B%5D=x&a%5B%5D=y&empty[]&nil\n", + "Xa=Xfirst&Xz=Xlast\n", + "a=1 a=1 a=1\n" + ) + ); +} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index b32b4ffa3c..b72de46d51 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -91,6 +91,23 @@ repo = "https://github.com/uuidjs/uuid" ref = "70177807e9229dfacde2038dc1e722f1828f358a" ported-at = "14.0.1" date = "2026-07-30" +[bindings.qs] +crate = "perry-ext-qs" +lib = "perry_ext_qs" +tracking = "#8751" +# Partial: stringify covers qs' ordinary scalar/object/array surface and the +# callback/options used by Stripe. parse covers the safe nested-query subset. +# Regex delimiters and the full decoder/filter extension-hook contracts remain +# intentionally outside this shim. +compat = "partial" + +[bindings.qs.upstream] +version = "6.15.3" +sha256 = "c0278b636e7a016d6e835cd8f194a63c276dff430620e4a04344a4ba8892c0f9" +repo = "https://github.com/ljharb/qs.git" +ref = "18d085e919dae70c8f1b200ab99323058edab2c2" +ported-at = "6.15.3" +date = "2026-08-25" [bindings.bcrypt] crate = "perry-ext-bcrypt" lib = "perry_ext_bcrypt" diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index c047c79845..dd9a302695 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -114,6 +114,7 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-parcel-watcher` | `@parcel/watcher`
`@parcel/watcher-darwin-arm64`
`@parcel/watcher-darwin-x64`
`@parcel/watcher-linux-arm64-glibc`
`@parcel/watcher-linux-arm64-musl`
`@parcel/watcher-linux-x64-glibc`
`@parcel/watcher-linux-x64-musl`
`@parcel/watcher-win32-arm64`
`@parcel/watcher-win32-x64` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-pdf` | `@perryts/pdf` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-pg` | `pg` | Source package | Compile the upstream package source | Bundled; migration pending | +| `perry-ext-qs` | `qs` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-ratelimit` | `rate-limiter-flexible` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-sharp` | `sharp` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-streams` | `streams` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | diff --git a/workspace-architecture.json b/workspace-architecture.json index eddd42c1b0..9f5fc0ee49 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 79, + "workspace_members": 80, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -66,7 +66,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 32, + "externalize": 33, "keep": 42, "merge": 1, "remove": 1, @@ -293,6 +293,11 @@ "decision": "externalize", "migration": "compile-source" }, + "perry-ext-qs": { + "category": "binding", + "decision": "externalize", + "migration": "compile-source" + }, "perry-ext-ratelimit": { "category": "binding", "decision": "externalize", From b807c13ec336a48064957df68117e4a700c7454f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:47:32 +0200 Subject: [PATCH 10/13] docs: add changelog fragment for sharp create --- changelog.d/8818-sharp-create.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8818-sharp-create.md diff --git a/changelog.d/8818-sharp-create.md b/changelog.d/8818-sharp-create.md new file mode 100644 index 0000000000..43924da192 --- /dev/null +++ b/changelog.d/8818-sharp-create.md @@ -0,0 +1 @@ +fix(sharp): support object-form `create` inputs with solid RGB or RGBA backgrounds, so `sharp({ create: ... })` can encode images instead of failing with an invalid handle. From 540f08429df48fe66582aa656aaa04371ac5ea83 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:50:33 +0200 Subject: [PATCH 11/13] fix(runtime): complete build identity inputs --- crates/perry-runtime/build.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/build.rs b/crates/perry-runtime/build.rs index 248044b192..122a29e245 100644 --- a/crates/perry-runtime/build.rs +++ b/crates/perry-runtime/build.rs @@ -61,6 +61,8 @@ use std::process::Command; const RUNTIME_BUILD_INPUTS: &[&str] = &[ "Cargo.toml", "Cargo.lock", + "crates/perry-dispatch/Cargo.toml", + "crates/perry-dispatch/src", "crates/perry/Cargo.toml", "crates/perry/src", "crates/perry-codegen/Cargo.toml", @@ -182,13 +184,13 @@ fn emit_runtime_build_id() { // files themselves are byte-identical (for example after a rebase). if workspace_layout { if let Some(git_head) = command_stdout(&root, &["rev-parse", "--git-path", "HEAD"]) { - println!("cargo:rerun-if-changed={git_head}"); + println!("cargo:rerun-if-changed={}", root.join(git_head).display()); } if let Some(symbolic_ref) = command_stdout(&root, &["symbolic-ref", "-q", "HEAD"]) { if let Some(git_ref) = command_stdout(&root, &["rev-parse", "--git-path", &symbolic_ref]) { - println!("cargo:rerun-if-changed={git_ref}"); + println!("cargo:rerun-if-changed={}", root.join(git_ref).display()); } } } From 9ec16b56283b25dbfcfa7185da09c2b26b2f52ea Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 25 Aug 2026 12:39:03 +0200 Subject: [PATCH 12/13] test(compile): cover compiled package builtin imports --- .../8817-compiled-package-builtin-import.md | 6 + ...ue_8749_compiled_package_builtin_import.rs | 114 ++++++++++++++++++ .../packages/hono-node-server/entry.ts | 16 +++ .../packages/hono-node-server/expected.txt | 2 + .../packages/hono-node-server/fixture.sh | 10 ++ .../hono-node-server/package-lock.json | 37 ++++++ .../packages/hono-node-server/package.json | 17 +++ 7 files changed, 202 insertions(+) create mode 100644 changelog.d/8817-compiled-package-builtin-import.md create mode 100644 crates/perry/tests/issue_8749_compiled_package_builtin_import.rs create mode 100644 tests/release/packages/hono-node-server/entry.ts create mode 100644 tests/release/packages/hono-node-server/expected.txt create mode 100755 tests/release/packages/hono-node-server/fixture.sh create mode 100644 tests/release/packages/hono-node-server/package-lock.json create mode 100644 tests/release/packages/hono-node-server/package.json diff --git a/changelog.d/8817-compiled-package-builtin-import.md b/changelog.d/8817-compiled-package-builtin-import.md new file mode 100644 index 0000000000..b62df17cf3 --- /dev/null +++ b/changelog.d/8817-compiled-package-builtin-import.md @@ -0,0 +1,6 @@ +Added regression coverage for Node builtin named imports used from natively +compiled dependencies. The exact `@hono/node-server` fallback from +`options.createServer` to its module-scope `http.createServer` import now has an +offline compiler fixture and a real-package listen/fetch/close release smoke, +covering both `http` and `node:http` spellings without relying on app-level +imports. diff --git a/crates/perry/tests/issue_8749_compiled_package_builtin_import.rs b/crates/perry/tests/issue_8749_compiled_package_builtin_import.rs new file mode 100644 index 0000000000..c3125b7627 --- /dev/null +++ b/crates/perry/tests/issue_8749_compiled_package_builtin_import.rs @@ -0,0 +1,114 @@ +//! Regression test for #8749: a Node builtin named import used from a +//! `compilePackages` dependency must retain its runtime binding. +//! +//! `@hono/node-server` imports `createServer` from `http`, selects it through a +//! module-scope fallback (`options.createServer || createServerHTTP`), and calls +//! the selected function later from `serve()`. App-level imports already +//! worked; the binding was lost specifically while compiling the dependency. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn builtin_named_import_survives_module_scope_fallback_in_compiled_package() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "compiled-builtin-import-consumer", + "private": true, + "type": "module", + "perry": { + "compilePackages": ["fake-node-server"], + "allow": { "compilePackages": ["fake-node-server"] } + } +}"#, + ) + .expect("write consumer package.json"); + + let pkg = root.join("node_modules").join("fake-node-server"); + std::fs::create_dir_all(&pkg).expect("mkdir fake-node-server"); + std::fs::write( + pkg.join("package.json"), + r#"{ + "name": "fake-node-server", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +}"#, + ) + .expect("write dependency package.json"); + std::fs::write( + pkg.join("index.mjs"), + r#" +import { createServer as createServerHTTP } from "http"; +import { createServer as createServerNodeHTTP } from "node:http"; + +const options = {}; +const selectedHTTP = options.createServer || createServerHTTP; +const selectedNodeHTTP = options.createServer || createServerNodeHTTP; + +export function inspectBindings() { + const serverHTTP = selectedHTTP({}, () => {}); + const serverNodeHTTP = selectedNodeHTTP({}, () => {}); + return [ + typeof createServerHTTP, + typeof createServerNodeHTTP, + typeof selectedHTTP, + typeof selectedNodeHTTP, + typeof serverHTTP.listen, + typeof serverNodeHTTP.listen, + ].join(","); +} +"#, + ) + .expect("write compiled dependency"); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import { inspectBindings } from "fake-node-server"; +console.log(inspectBindings()); +process.exit(0); +"#, + ) + .expect("write entry"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .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).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) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "function,function,function,function,function,function\n", + "both builtin spellings must stay bound through the dependency's module global" + ); +} diff --git a/tests/release/packages/hono-node-server/entry.ts b/tests/release/packages/hono-node-server/entry.ts new file mode 100644 index 0000000000..c11cf339e7 --- /dev/null +++ b/tests/release/packages/hono-node-server/entry.ts @@ -0,0 +1,16 @@ +// Issue #8749: @hono/node-server selects its imported node:http factory +// through `options.createServer || createServerHTTP` inside compiled package +// code. Exercise the real package through listen, fetch, response, and close. +import { serve } from "@hono/node-server"; + +const port = 38139; +const server = serve({ + fetch: () => new Response("ok"), + port, +}, async () => { + const response = await fetch(`http://127.0.0.1:${port}/`); + console.log(`status=${response.status}`); + console.log(`body=${await response.text()}`); + server.close(); + process.exit(0); +}); diff --git a/tests/release/packages/hono-node-server/expected.txt b/tests/release/packages/hono-node-server/expected.txt new file mode 100644 index 0000000000..e03d31153e --- /dev/null +++ b/tests/release/packages/hono-node-server/expected.txt @@ -0,0 +1,2 @@ +status=200 +body=ok diff --git a/tests/release/packages/hono-node-server/fixture.sh b/tests/release/packages/hono-node-server/fixture.sh new file mode 100755 index 0000000000..5e49ae2828 --- /dev/null +++ b/tests/release/packages/hono-node-server/fixture.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Issue #8749: real-package smoke for @hono/node-server's module-scope +# `options.createServer || createServerHTTP` binding under compilePackages. + +set -uo pipefail +cd "$(dirname "$0")" +. "$(dirname "$0")/../_fixture_lib.sh" + +fixture_setup "hono-node-server" || exit 1 +fixture_compile_run_diff "hono-node-server" diff --git a/tests/release/packages/hono-node-server/package-lock.json b/tests/release/packages/hono-node-server/package-lock.json new file mode 100644 index 0000000000..bd5dbd0074 --- /dev/null +++ b/tests/release/packages/hono-node-server/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "perry-release-fixture-hono-node-server", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-release-fixture-hono-node-server", + "version": "0.0.0", + "dependencies": { + "@hono/node-server": "1.19.17", + "hono": "4.13.4" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/hono": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", + "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + } + } +} diff --git a/tests/release/packages/hono-node-server/package.json b/tests/release/packages/hono-node-server/package.json new file mode 100644 index 0000000000..ed2aa1437d --- /dev/null +++ b/tests/release/packages/hono-node-server/package.json @@ -0,0 +1,17 @@ +{ + "name": "perry-release-fixture-hono-node-server", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tier-3 fixture for @hono/node-server's compiled-package Node builtin imports.", + "dependencies": { + "@hono/node-server": "1.19.17", + "hono": "4.13.4" + }, + "perry": { + "compilePackages": ["@hono/node-server", "hono"], + "allow": { + "compilePackages": ["@hono/node-server", "hono"] + } + } +} From f7000a266261c898c4613ebdf952c686ade44876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 13:04:18 +0200 Subject: [PATCH 13/13] chore: add changelog for array-store optimization --- changelog.d/8820-call-return-array-stores.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8820-call-return-array-stores.md diff --git a/changelog.d/8820-call-return-array-stores.md b/changelog.d/8820-call-return-array-stores.md new file mode 100644 index 0000000000..2e1c50ac40 --- /dev/null +++ b/changelog.d/8820-call-return-array-stores.md @@ -0,0 +1 @@ +Array index assignments whose base is a call expression now preserve the call's statically known Array type and use the typed array-store path while evaluating the base exactly once. Strict writes through that path also honor non-writable and accessor descriptors, read-only length, and non-extensible holes.