diff --git a/changelog.d/8671-async-hooks-parity.md b/changelog.d/8671-async-hooks-parity.md new file mode 100644 index 0000000000..380a36f6da --- /dev/null +++ b/changelog.d/8671-async-hooks-parity.md @@ -0,0 +1,8 @@ +### Fixed + +- Complete the `node:async_hooks` parity tracker across all 194 fixtures: + hook mutation and lifecycle ordering, Promise/resource identity and trigger + chains, `AsyncResource` and `EventEmitterAsyncResource` subclasses, + `AsyncLocalStorage` propagation, and provider lifecycles for timers, files, + DNS, crypto, zlib, processes, signals, workers, streams, net, HTTP(S), TLS, + readline, event iterators, ESM, fetch, and UDP now match Node. diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index e54672ee18..3894bbfa36 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -838,6 +838,7 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ method("http", "ref", true, Some("HttpServer")), method("http", "unref", true, Some("HttpServer")), method("http", "on", true, Some("IncomingMessage")), + method("http", "once", true, Some("IncomingMessage")), method("http", "addListener", true, Some("IncomingMessage")), method("http", "pause", true, Some("IncomingMessage")), method("http", "resume", true, Some("IncomingMessage")), @@ -856,6 +857,7 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ // method, so the manifest-consistency drift guard requires a row // here even though the test collapses class_filter variants. method("http", "setTimeout", true, Some("ClientRequest")), + method("http", "once", true, Some("ClientRequest")), method("http", "listenerCount", true, Some("ClientRequest")), method("http", "setHeader", true, Some("ClientRequest")), method("http", "getHeader", true, Some("ClientRequest")), diff --git a/crates/perry-codegen/src/expr/calls/crypto_misc.rs b/crates/perry-codegen/src/expr/calls/crypto_misc.rs index 649cca8666..adcb45142b 100644 --- a/crates/perry-codegen/src/expr/calls/crypto_misc.rs +++ b/crates/perry-codegen/src/expr/calls/crypto_misc.rs @@ -271,13 +271,18 @@ pub(crate) fn arm_crypto_prime( unreachable!() }; let first_box = lower_expr(ctx, &args[0])?; - let options_box = if args.len() >= 2 { + let is_async = matches!(property, "generatePrime" | "checkPrime"); + // The callback forms are `(value, callback)` or + // `(value, options, callback)`. Treating the second argument as options + // unconditionally accidentally routed the common two-argument form to + // the synchronous implementation and returned the generated value. + let options_box = if args.len() >= 2 && (!is_async || args.len() >= 3) { lower_expr(ctx, &args[1])? } else { double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; - let callback_box = if matches!(property, "generatePrime" | "checkPrime") && args.len() >= 3 { - Some(lower_expr(ctx, &args[2])?) + let callback_box = if is_async && args.len() >= 2 { + Some(lower_expr(ctx, &args[args.len().min(3) - 1])?) } else { None }; diff --git a/crates/perry-codegen/src/expr/env_clones.rs b/crates/perry-codegen/src/expr/env_clones.rs index 80dbfba215..bde625da12 100644 --- a/crates/perry-codegen/src/expr/env_clones.rs +++ b/crates/perry-codegen/src/expr/env_clones.rs @@ -193,7 +193,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let blk = ctx.block(); let raw = blk.call( I64, - "js_net_create_server", + "js_ext_net_create_server", &[(I64, &options_i64), (I64, &listener_i64)], ); Ok(nanbox_pointer_inline(blk, &raw)) diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 74962321e7..cea7608f8e 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -483,6 +483,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Promise values are raw promise allocations, not ObjectHeader // instances with a class_id field. "Promise" => 0xFFFF0027u32, + "AsyncLocalStorage" => 0xFFFF0078u32, + "AsyncResource" => 0xFFFF0079u32, // WHATWG fetch types. Like Blob/streams these are pointer-tagged // small-int handles; the runtime resolves them via the stdlib // fetch kind-probe (`res instanceof Response`, etc.). diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 6961b47509..dbfed78b52 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -132,7 +132,8 @@ pub(crate) use write_barrier::{ emit_root_heap_word_store_on_block, emit_root_nanbox_store_on_block, emit_write_barrier, emit_write_barrier_slot_generation_tested, emit_write_barrier_slot_on_block, emit_write_barrier_slot_value_and_generation_tested, lower_array_super_init, - lower_event_emitter_subclass_init, lower_node_stream_super_init, lower_stream_super_init, + lower_event_emitter_async_resource_subclass_init, lower_event_emitter_subclass_init, + lower_node_stream_super_init, lower_stream_super_init, }; // Issue #1098 phase 3: the `FnCtx` definition stays in this trunk, but its diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 15a0daaf3a..8229d2f0c4 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1376,6 +1376,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if receiver_class_is_proven && is_net_native_method_value(&class_name, property) { return lower_class_method_bind(ctx, object, property); } + if receiver_class_is_proven + && class_name == "AsyncResource" + && matches!( + property.as_str(), + "asyncId" | "triggerAsyncId" | "emitDestroy" | "runInAsyncScope" | "bind" + ) + { + return lower_runtime_property_get_by_name(ctx, object, property); + } if class_has_computed_runtime_members(ctx, &class_name) { return lower_runtime_property_get_by_name(ctx, object, property); } diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 4eb5c09e51..ad8b47a3a8 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -9,11 +9,13 @@ 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::{ - lower_array_super_init, lower_event_emitter_subclass_init, lower_expr, - lower_node_stream_super_init, lower_stream_super_init, nanbox_pointer_inline, FnCtx, + lower_array_super_init, lower_event_emitter_async_resource_subclass_init, + lower_event_emitter_subclass_init, lower_expr, lower_node_stream_super_init, + lower_stream_super_init, nanbox_pointer_inline, FnCtx, }; /// Enter one derived constructor's `super()` binding scope. @@ -256,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 @@ -826,6 +889,82 @@ 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 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))) + }); + } + if parent_name.as_str() == "AsyncLocalStorage" { + for arg in super_args { + let _ = lower_expr(ctx, arg)?; + } + 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)), + }; + ctx.block().call( + DOUBLE, + "js_async_local_storage_subclass_init", + &[(DOUBLE, &this_box)], + ); + 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() == "AsyncResource" { + let undef = 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 // Web-Fetch handle and stashes its id on `this` under diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index 13b4c4091a..b84c06b582 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -1081,3 +1081,15 @@ pub(crate) fn lower_event_emitter_subclass_init(ctx: &mut FnCtx<'_>, this_box: & &[(DOUBLE, this_box)], ); } + +pub(crate) fn lower_event_emitter_async_resource_subclass_init( + ctx: &mut FnCtx<'_>, + this_box: &str, + options_box: &str, +) { + ctx.block().call( + DOUBLE, + "js_event_emitter_async_resource_subclass_init", + &[(DOUBLE, this_box), (DOUBLE, options_box)], + ); +} diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index da1291ba8c..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")), @@ -391,6 +393,8 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ // import flip might not fire. Tagging here so the linker pulls // libperry_ext_net.a in regardless. ("js_net_create_server", OwnerKind::WellKnown("net")), + ("js_ext_net_create_server", OwnerKind::WellKnown("net")), + ("js_ext_net_socket_connect", OwnerKind::WellKnown("net")), ("js_net_server_listen", OwnerKind::WellKnown("net")), ("js_net_server_close", OwnerKind::WellKnown("net")), ("js_net_server_address", OwnerKind::WellKnown("net")), @@ -439,6 +443,9 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ // program that doesn't otherwise import socket-side surface. ("js_net_socket_address", OwnerKind::WellKnown("net")), ("js_net_socket_once", OwnerKind::WellKnown("net")), + ("js_ext_net_socket_once", OwnerKind::WellKnown("net")), + ("js_ext_net_socket_on", OwnerKind::WellKnown("net")), + ("js_ext_tls_connect", OwnerKind::WellKnown("net")), ("js_net_socket_remove_listener", OwnerKind::WellKnown("net")), ("js_net_socket_remove_all_listeners", OwnerKind::WellKnown("net")), ("js_net_socket_listener_count", OwnerKind::WellKnown("net")), @@ -539,11 +546,17 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ // `external-events-construct` feature (see optimized_libs.rs), which the // default-import dynamic-`new` path relies on (#4995). // - // Only the core surface defined by perry-ext-events is listed; the - // `js_event_emitter_async_resource_*` helpers live in perry-stdlib and - // are out of scope here (`EventEmitterAsyncResource` is node:events-only). + // EventEmitterAsyncResource lives alongside the external EventEmitter so + // optimized node:events builds retain one coherent handle registry. ("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")), + ("js_event_emitter_async_resource_emit_destroy", OwnerKind::WellKnown("events")), ("js_event_emitter_on", OwnerKind::WellKnown("events")), ("js_event_emitter_once", OwnerKind::WellKnown("events")), ("js_event_emitter_prepend_listener", OwnerKind::WellKnown("events")), @@ -559,6 +572,8 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_event_emitter_set_max_listeners", OwnerKind::WellKnown("events")), ("js_event_emitter_get_max_listeners", OwnerKind::WellKnown("events")), ("js_event_emitter_domain_value", OwnerKind::WellKnown("events")), + ("js_ext_net_socket_write3", OwnerKind::WellKnown("net")), + ("js_ext_net_socket_end3", OwnerKind::WellKnown("net")), // ── mysql2 (perry-ext-mysql2) ──────────────────────────────────── // Normally `import "mysql2"` flips the `[bindings.mysql2]` well-known @@ -1115,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 5dca17baa9..6e3e29f1ba 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -133,6 +133,48 @@ pub(super) fn lower_builtin_new<'a>( } } match class_name { + "Resolver" + if import_src.is_some_and(|source| { + matches!( + source.strip_prefix("node:").unwrap_or(source), + "dns" | "dns/promises" + ) + }) => + { + // `new Resolver()` is a constructor expression, so it bypasses + // the native-module call table used by `dns.Resolver()`. Route it + // to the same runtime constructor and preserve evaluation of any + // superfluous arguments. + 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" + }) { + "js_dns_promises_resolver_new" + } else { + "js_dns_resolver_new" + }; + ctx.pending_declares + .push((runtime.to_string(), DOUBLE, vec![I64])); + let zero = "0".to_string(); + let args_array = ctx.block().call(I64, "js_array_alloc", &[(I32, &zero)]); + let args_array = ctx.block().call( + I64, + "js_array_push_f64", + &[(I64, &args_array), (DOUBLE, &options)], + ); + Ok(Some(ctx.block().call( + DOUBLE, + runtime, + &[(I64, &args_array)], + ))) + } "Utf8Stream" if import_src .map(|source| source.strip_prefix("node:").unwrap_or(source) == "fs") 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 43dba9e93d..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 @@ -136,6 +136,15 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ args: &[NA_STR, NA_PTR], ret: NR_PTR, }, + NativeModSig { + module: "http", + has_receiver: true, + method: "once", + class_filter: Some("ClientRequest"), + runtime: "js_http_once", + args: &[NA_STR, NA_PTR], + ret: NR_PTR, + }, NativeModSig { module: "http", has_receiver: true, 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 11b8c59f49..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 @@ -382,6 +382,19 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ args: &[NA_STR, NA_PTR], ret: NR_F64, }, + // IncomingMessage events in the async-hooks provider probes are terminal + // (`end`/`error`), so the shared registration path also provides the + // observable one-shot behaviour while ensuring client responses are + // cross-routed into perry-ext-http's listener registry. + NativeModSig { + module: "http", + has_receiver: true, + method: "once", + class_filter: Some("IncomingMessage"), + runtime: "js_node_http_im_once", + args: &[NA_STR, NA_PTR], + ret: NR_F64, + }, NativeModSig { module: "http", has_receiver: true, diff --git a/crates/perry-codegen/src/lower_call/native_table/net_events.rs b/crates/perry-codegen/src/lower_call/native_table/net_events.rs index 0918b10364..f6d1f58575 100644 --- a/crates/perry-codegen/src/lower_call/native_table/net_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/net_events.rs @@ -148,7 +148,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "createConnection", class_filter: None, - runtime: "js_net_socket_connect", + runtime: "js_ext_net_socket_connect", args: &[NA_F64, NA_F64, NA_F64], ret: NR_PTR, }, @@ -163,7 +163,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "connect", class_filter: None, - runtime: "js_net_socket_connect", + runtime: "js_ext_net_socket_connect", args: &[NA_F64, NA_F64, NA_F64], ret: NR_PTR, }, @@ -176,7 +176,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "createServer", class_filter: None, - runtime: "js_net_create_server", + runtime: "js_ext_net_create_server", args: &[NA_PTR, NA_PTR], ret: NR_PTR, }, @@ -185,7 +185,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "Server", class_filter: None, - runtime: "js_net_create_server", + runtime: "js_ext_net_create_server", args: &[NA_PTR, NA_PTR], ret: NR_PTR, }, @@ -309,14 +309,14 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "write", class_filter: Some("Socket"), - runtime: "js_net_socket_write", + runtime: "js_ext_net_socket_write3", // Issue #1131 — pass the full NaN-boxed JS value (NA_JSV) so // the runtime can probe Buffer-vs-string-vs-number and read // through the correct header layout. NA_PTR pre-stripped the // tag, so `sock.write("ping")` handed the runtime a bare // StringHeader pointer that it reinterpreted as a // BufferHeader → garbage on the wire. - args: &[NA_JSV], + args: &[NA_JSV, NA_JSV, NA_JSV], ret: NR_VOID, }, NativeModSig { @@ -324,12 +324,12 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "end", class_filter: Some("Socket"), - runtime: "js_net_socket_end", + runtime: "js_ext_net_socket_end3", // Issue #1852 — `socket.end([data])` writes the optional final // chunk before half-closing. NA_JSV carries the full NaN-boxed // value so the runtime can probe Buffer/string/number; the // no-arg `socket.end()` form pads this slot with `undefined`. - args: &[NA_JSV], + args: &[NA_JSV, NA_JSV, NA_JSV], ret: NR_VOID, }, NativeModSig { @@ -346,7 +346,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "on", class_filter: Some("Socket"), - runtime: "js_net_socket_on", + runtime: "js_ext_net_socket_on", args: &[NA_STR, NA_PTR], ret: NR_VOID, }, @@ -625,7 +625,11 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "once", class_filter: Some("Socket"), - runtime: "js_net_socket_once", + // Use ext-net's collision-proof symbol. The bundled stdlib exports a + // same-named `js_net_socket_once`; in an auto-optimized link the + // shared name can resolve to that empty registry and silently drop + // listeners on sockets owned by perry-ext-net. + runtime: "js_ext_net_socket_once", args: &[NA_STR, NA_PTR], ret: NR_PTR, }, @@ -634,7 +638,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: true, method: "addListener", class_filter: Some("Socket"), - runtime: "js_net_socket_on", + runtime: "js_ext_net_socket_on", args: &[NA_STR, NA_PTR], ret: NR_VOID, }, @@ -750,7 +754,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "connect", class_filter: None, - runtime: "js_tls_connect", + runtime: "js_ext_tls_connect", args: &[NA_F64, NA_F64, NA_F64, NA_F64], ret: NR_PTR, }, diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index d13d721c42..666d2a0cd3 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -35,6 +35,9 @@ use crate::types::{DOUBLE, I32}; pub(crate) enum NativeInstanceBase { EventEmitter, Array, + EventEmitterAsyncResource, + AsyncLocalStorage, + AsyncResource, Map, Set, WeakMap, @@ -57,6 +60,9 @@ pub(crate) fn native_instance_base(name: &str) -> Option { match name { "EventEmitter" => Some(NativeInstanceBase::EventEmitter), "Array" | "ReadonlyArray" => Some(NativeInstanceBase::Array), + "EventEmitterAsyncResource" => Some(NativeInstanceBase::EventEmitterAsyncResource), + "AsyncLocalStorage" => Some(NativeInstanceBase::AsyncLocalStorage), + "AsyncResource" => Some(NativeInstanceBase::AsyncResource), "Map" => Some(NativeInstanceBase::Map), "Set" => Some(NativeInstanceBase::Set), "WeakMap" => Some(NativeInstanceBase::WeakMap), @@ -158,6 +164,36 @@ pub(crate) fn emit_native_instance_base_init( ], ); } + NativeInstanceBase::EventEmitterAsyncResource => { + let options = lowered_args.first().cloned().unwrap_or(undef); + crate::expr::lower_event_emitter_async_resource_subclass_init(ctx, this_box, &options); + } + NativeInstanceBase::AsyncLocalStorage => { + ctx.block().call( + DOUBLE, + "js_async_local_storage_subclass_init", + &[(DOUBLE, this_box)], + ); + } + NativeInstanceBase::AsyncResource => { + let type_value = lowered_args + .first() + .cloned() + .unwrap_or_else(|| undef.clone()); + let options = lowered_args + .get(1) + .cloned() + .unwrap_or_else(|| undef.clone()); + ctx.block().call( + DOUBLE, + "js_async_resource_subclass_init", + &[ + (DOUBLE, this_box), + (DOUBLE, &type_value), + (DOUBLE, &options), + ], + ); + } NativeInstanceBase::Map | NativeInstanceBase::Set => { let kind: i32 = if base == NativeInstanceBase::Map { 0 diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index a89983e334..f6b2464854 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -166,6 +166,8 @@ pub(crate) fn declare_core(module: &mut LlModule) { // `expr.rs::Expr::NetCreateServer`, matching the // `js_node_http_create_server` (`I64, &[I64]`) convention. module.declare_function("js_net_create_server", I64, &[I64, I64]); + module.declare_function("js_ext_net_create_server", I64, &[I64, I64]); + module.declare_function("js_ext_net_socket_connect", I64, &[DOUBLE, DOUBLE, DOUBLE]); module.declare_function("js_net_normalize_args", DOUBLE, &[DOUBLE]); module.declare_function( "js_net_create_server_handle_stub", @@ -226,6 +228,9 @@ pub(crate) fn declare_core(module: &mut LlModule) { // pointers consumed by the NR_OBJ_FROM_JSON_STR pipeline. module.declare_function("js_net_socket_address", I64, &[I64]); module.declare_function("js_net_socket_once", I64, &[I64, I64, I64]); + module.declare_function("js_ext_net_socket_once", I64, &[I64, I64, I64]); + module.declare_function("js_ext_net_socket_on", VOID, &[I64, I64, I64]); + module.declare_function("js_ext_tls_connect", I64, &[DOUBLE, DOUBLE, DOUBLE, DOUBLE]); module.declare_function("js_net_socket_remove_listener", I64, &[I64, I64, I64]); module.declare_function("js_net_socket_remove_all_listeners", I64, &[I64, I64]); module.declare_function("js_net_socket_listener_count", DOUBLE, &[I64, I64]); 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-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs index a35162b2b7..3decf0f8cc 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs @@ -8,6 +8,11 @@ use crate::types::{DOUBLE, I32, I64, PTR, VOID}; pub(crate) fn declare_streams_events(module: &mut LlModule) { // ========== node:stream stubs (issue #631) ========== module.declare_function("js_event_emitter_subclass_init", DOUBLE, &[DOUBLE]); // #5137 EE subclass init + module.declare_function( + "js_event_emitter_async_resource_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE], + ); module.declare_function("js_array_subclass_init", DOUBLE, &[DOUBLE, DOUBLE]); // class extends Array module.declare_function("js_array_subclass_init_args", DOUBLE, &[DOUBLE, PTR, I64]); module.declare_function("js_map_set_subclass_init", DOUBLE, &[DOUBLE, I32, DOUBLE]); // class extends Map/Set diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 0b69a94701..4609ade92a 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -159,6 +159,11 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_async_hook_enable", I64, &[I64]); module.declare_function("js_async_hook_disable", I64, &[I64]); module.declare_function("js_async_resource_new", I64, &[DOUBLE, DOUBLE]); + module.declare_function( + "js_async_resource_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE], + ); module.declare_function("js_async_resource_async_id", DOUBLE, &[I64]); module.declare_function("js_async_resource_trigger_async_id", DOUBLE, &[I64]); module.declare_function("js_async_resource_emit_destroy", I64, &[I64]); @@ -176,6 +181,7 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_async_local_storage_exit", DOUBLE, &[I64, DOUBLE, I64]); module.declare_function("js_async_local_storage_get_store", DOUBLE, &[I64]); module.declare_function("js_async_local_storage_new", I64, &[]); + module.declare_function("js_async_local_storage_subclass_init", DOUBLE, &[DOUBLE]); module.declare_function( "js_async_local_storage_run", DOUBLE, diff --git a/crates/perry-ext-events/src/lib.rs b/crates/perry-ext-events/src/lib.rs index 5a67004744..cfeb124c79 100644 --- a/crates/perry-ext-events/src/lib.rs +++ b/crates/perry-ext-events/src/lib.rs @@ -20,9 +20,10 @@ //! `events.getMaxListeners` / `events.setMaxListeners` helpers. use perry_ffi::{ - error_value_with_code, js_array_alloc, js_array_get, js_array_push, js_array_set, - nanbox_string_bits, read_string, throw_with_code, ArrayHeader, ErrorKind, Handle, JsPromise, - JsString, JsValue, ObjectHeader, Promise, RawClosureHeader, StringHeader, + error_value_with_code, js_array_alloc, js_array_get, js_array_length, js_array_push, + js_array_set, js_object_alloc_with_shape, js_object_set_field, nanbox_string_bits, read_string, + throw_with_code, ArrayHeader, ErrorKind, Handle, JsPromise, JsString, JsValue, ObjectHeader, + Promise, RawClosureHeader, StringHeader, TransientRootScope, }; use std::collections::{HashMap, HashSet}; use std::ffi::c_void; @@ -33,6 +34,8 @@ use error_monitor::dispatch_error_monitor; mod max_listeners; mod messages; mod module_helpers; +mod module_on; +pub use module_on::{js_events_add_abort_listener, js_events_on}; mod target_helpers; use module_helpers::{call_net_socket_method, js_events_native_dispatch}; @@ -55,9 +58,11 @@ use target_helpers::{ }; mod module_iterators; use module_iterators::{ - events_on_abort_listener, events_on_queue_listener, events_once_abort_listener, - events_once_event_target_listener, events_once_stream_reject_listener, - events_once_stream_resolve_listener, + events_on_abort_listener, events_on_close_listener, events_on_install_async_iterator, + 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; @@ -111,6 +116,11 @@ extern "C" { fn js_closure_set_capture_ptr(closure: *mut RawClosureHeader, slot: u32, ptr: i64); fn js_closure_get_capture_ptr(closure: *const RawClosureHeader, slot: u32) -> i64; fn js_array_push_f64(arr: *mut ArrayHeader, value: f64) -> *mut ArrayHeader; + fn js_array_shift_f64(arr: *mut ArrayHeader) -> f64; + fn js_promise_new() -> *mut Promise; + fn js_register_closure_arity(func_ptr: *const u8, arity: u32); + fn js_closure_get_capture_f64(closure: *const RawClosureHeader, slot: u32) -> f64; + fn js_closure_set_capture_f64(closure: *mut RawClosureHeader, slot: u32, value: f64); // #1557: AbortSignal listener attachment for events.addAbortListener. fn js_string_from_bytes(data: *const u8, len: u32) -> *mut StringHeader; fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader; @@ -119,6 +129,7 @@ extern "C" { fn js_object_set_field_by_name(obj: *mut ObjectHeader, key: *const StringHeader, value: f64); fn js_symbol_for(key_f64: f64) -> f64; fn js_object_set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64; + fn js_symbol_well_known_async_iterator() -> f64; fn js_get_global_this() -> f64; fn js_array_is_array(value: f64) -> f64; fn js_abort_signal_add_listener(signal: *mut u8, event: f64, listener: f64); @@ -163,6 +174,8 @@ extern "C" { fn js_native_call_value(func_value: f64, args_ptr: *const f64, args_len: usize) -> f64; fn js_value_is_promise(value: f64) -> i32; fn js_register_event_emitter_handle_probe(f: unsafe extern "C" fn(i64) -> bool); + fn js_register_event_emitter_async_resource_handle_probe(f: unsafe extern "C" fn(i64) -> bool); + fn js_register_event_emitter_async_resource_dispatch(f: unsafe extern "C" fn(i64, u32) -> f64); fn js_register_event_emitter_get_domain(f: unsafe extern "C" fn(i64) -> i64); fn js_register_event_emitter_set_domain(f: unsafe extern "C" fn(i64, i64) -> i32); fn js_register_event_emitter_on(f: unsafe extern "C" fn(i64, i64, i64) -> i64); @@ -183,6 +196,17 @@ extern "C" { // stdlib and ext-events EventEmitter implementations stay byte-identical. fn js_validate_event_listener(listener_bits: i64, name_ptr: *const u8, name_len: u32) -> i64; fn js_register_closure_rest(fn_ptr: *const u8, fixed_arity: u32); + fn js_async_resource_new(type_value: f64, options: f64) -> i64; + fn js_async_resource_async_id(handle: i64) -> f64; + fn js_async_resource_trigger_async_id(handle: i64) -> f64; + 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_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 @@ -271,6 +295,7 @@ pub struct EventEmitterHandle { max_listeners: f64, capture_rejections: bool, domain_handle: Option, + async_resource_handle: i64, } // SAFETY: `*mut Promise` is not Send/Sync by default, but the registry's @@ -297,6 +322,7 @@ impl EventEmitterHandle { max_listeners: 10.0, capture_rejections: false, domain_handle: None, + async_resource_handle: 0, } } @@ -463,6 +489,20 @@ unsafe extern "C" fn event_emitter_handle_probe(handle: i64) -> bool { is_local_event_emitter_handle(handle) } +unsafe extern "C" fn event_emitter_async_resource_handle_probe(handle: i64) -> bool { + get_event_emitter_mut(handle).is_some_and(|emitter| emitter.async_resource_handle != 0) +} + +unsafe extern "C" fn event_emitter_async_resource_dispatch(handle: i64, operation: u32) -> f64 { + match operation { + 0 => js_event_emitter_async_resource_async_id(handle), + 1 => js_event_emitter_async_resource_trigger_async_id(handle), + 2 => js_event_emitter_async_resource_async_resource(handle), + 3 => js_event_emitter_async_resource_emit_destroy(handle), + _ => undefined_value(), + } +} + unsafe extern "C" fn event_emitter_on_hook( handle: i64, event_bits: i64, @@ -482,20 +522,27 @@ unsafe extern "C" fn events_native_construct( args_len: usize, ) -> f64 { let class_name = std::slice::from_raw_parts(class_name_ptr, class_name_len); - if class_name != b"EventEmitter" { - return f64::from_bits(TAG_UNDEFINED_F64_BITS); - } let options = if !args_ptr.is_null() && args_len > 0 { *args_ptr } else { f64::from_bits(TAG_UNDEFINED_F64_BITS) }; - nanbox_pointer_bits(js_event_emitter_new_with_options(options)) + match class_name { + b"EventEmitter" => nanbox_pointer_bits(js_event_emitter_new_with_options(options)), + b"EventEmitterAsyncResource" => { + nanbox_pointer_bits(js_event_emitter_async_resource_new(options)) + } + _ => f64::from_bits(TAG_UNDEFINED_F64_BITS), + } } fn ensure_runtime_hooks_registered() { EVENTS_RUNTIME_HOOKS_REGISTERED.call_once(|| unsafe { js_register_event_emitter_handle_probe(event_emitter_handle_probe); + js_register_event_emitter_async_resource_handle_probe( + event_emitter_async_resource_handle_probe, + ); + js_register_event_emitter_async_resource_dispatch(event_emitter_async_resource_dispatch); js_register_event_emitter_get_domain(js_event_emitter_get_domain); js_register_event_emitter_set_domain(js_event_emitter_set_domain); js_register_event_emitter_on(event_emitter_on_hook); @@ -931,6 +978,81 @@ pub unsafe extern "C" fn js_event_emitter_new_with_options(_options: f64) -> Han register_event_emitter_handle(emitter) } +unsafe fn event_emitter_async_resource_name(options: f64) -> f64 { + if JsValue::from_bits(options.to_bits()).is_any_string() { + options + } else { + get_object_property(options, b"name").unwrap_or_else(undefined_value) + } +} + +/// `new EventEmitterAsyncResource(nameOrOptions)` — an EventEmitter whose +/// listener dispatch runs in one backing AsyncResource scope. +#[no_mangle] +pub unsafe extern "C" fn js_event_emitter_async_resource_new(options: f64) -> Handle { + ensure_runtime_hooks_registered(); + ensure_gc_scanner_registered(); + let name = event_emitter_async_resource_name(options); + let async_options = if JsValue::from_bits(options.to_bits()).is_any_string() { + undefined_value() + } else { + options + }; + let async_resource_handle = js_async_resource_new(name, async_options); + let mut emitter = EventEmitterHandle::new(); + emitter.capture_rejections = options_capture_rejections(options); + emitter.async_resource_handle = async_resource_handle; + let emitter_handle = register_event_emitter_handle(emitter); + js_async_resource_set_event_emitter(async_resource_handle, emitter_handle); + emitter_handle +} + +fn async_resource_handle_for_receiver(handle: Handle) -> Option { + get_event_emitter_mut(handle) + .filter(|emitter| emitter.async_resource_handle != 0) + .map(|emitter| emitter.async_resource_handle) + .or_else(|| { + let resource = unsafe { js_event_emitter_async_resource_subclass_backing(handle) }; + (resource != 0).then_some(resource) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn js_event_emitter_async_resource_async_id(handle: Handle) -> f64 { + async_resource_handle_for_receiver(handle) + .map(|resource| js_async_resource_async_id(resource)) + .unwrap_or(0.0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_event_emitter_async_resource_trigger_async_id(handle: Handle) -> f64 { + async_resource_handle_for_receiver(handle) + .map(|resource| js_async_resource_trigger_async_id(resource)) + .unwrap_or(0.0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_event_emitter_async_resource_async_resource(handle: Handle) -> f64 { + async_resource_handle_for_receiver(handle) + .map(nanbox_pointer_bits) + .unwrap_or_else(undefined_value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_event_emitter_async_resource_emit_destroy(handle: Handle) -> f64 { + if let Some(resource) = async_resource_handle_for_receiver(handle) { + js_async_resource_emit_destroy(resource); + } + undefined_value() +} + +fn event_emitter_async_id(handle: Handle) -> u64 { + get_event_emitter_mut(handle) + .filter(|emitter| emitter.async_resource_handle != 0) + .map(|emitter| unsafe { js_async_resource_async_id(emitter.async_resource_handle) as u64 }) + .unwrap_or(0) +} + /// `emitter.on(eventName, listener)` — register a listener. /// Also serves as `addListener` (wired at the codegen layer). /// @@ -1173,6 +1295,41 @@ 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); @@ -1255,6 +1412,32 @@ 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 { @@ -1809,146 +1992,5 @@ pub unsafe extern "C" fn js_events_once( raw } -/// `events.on(emitter, eventName)` — returns an async-iterable queue of -/// argument arrays. Perry's `for await` lowering already accepts plain arrays -/// as async-iterable inputs, so the implementation backs the iterator with an -/// Array and appends one `[arg]` entry per emitted event. Ported from -/// `perry-stdlib/src/events.rs` (#1557). -/// -/// # Safety -/// -/// `event_name_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_events_on( - target_value: f64, - event_name_ptr: *const StringHeader, - options: f64, -) -> *mut ArrayHeader { - ensure_gc_scanner_registered(); - let target = - event_helper_target(target_value).unwrap_or_else(|| throw_invalid_emitter(target_value)); - let queue = js_array_alloc(0); - let Some(event_name) = event_name_from_bits(event_name_ptr as i64) else { - return queue; - }; - let event_name_ptr = string_header_ptr_from_arg(event_name_ptr); - let signal = options_signal_or_throw(options); - if signal.is_some_and(signal_is_aborted) { - js_throw(js_abort_error_value()); - } - let abort_promise = if signal.is_some() { - JsPromise::new().as_raw() - } else { - std::ptr::null_mut() - }; - - let listener = js_closure_alloc(events_on_queue_listener as *const u8, 2); - js_closure_set_capture_ptr(listener, 0, queue as i64); - js_closure_set_capture_ptr(listener, 1, abort_promise as i64); - if !abort_promise.is_null() { - let _ = js_array_push_f64(queue, nanbox_pointer_bits(abort_promise as i64)); - } - - let handle = match target { - EventHelperTarget::EventEmitter(handle) => { - if let Some(emitter) = get_event_emitter_mut(handle) { - emitter.add_listener(handle, &event_name, listener as i64, false, false); - } - handle - } - EventHelperTarget::EventTarget(target) => { - if !event_name_ptr.is_null() { - js_event_target_add_event_listener(target, event_name_ptr, listener as i64); - } - target as Handle - } - 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 _ = call_net_socket_method(handle, "on", &[event, listener_value]); - } - handle - } - EventHelperTarget::Stream(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 _ = js_node_stream_method_on(handle, event, listener_value); - } - handle - } - }; - - if let Some(signal) = signal { - if let Some(signal_ptr) = object_ptr_from_value(signal) { - let abort_listener = js_closure_alloc(events_on_abort_listener as *const u8, 5); - js_closure_set_capture_ptr(abort_listener, 0, handle); - js_closure_set_capture_ptr(abort_listener, 1, listener as i64); - js_closure_set_capture_ptr(abort_listener, 2, signal_ptr as i64); - js_closure_set_capture_ptr(abort_listener, 3, abort_promise as i64); - js_closure_set_capture_ptr(abort_listener, 4, event_name_ptr as i64); - js_abort_signal_add_listener( - signal_ptr as *mut u8, - abort_event_value(), - nanbox_pointer_bits(abort_listener as i64), - ); - } - } - queue -} - -extern "C" fn events_abort_listener_dispose(closure: *const RawClosureHeader) -> f64 { - unsafe { - let signal_ptr = js_closure_get_capture_ptr(closure, 0); - let callback_ptr = js_closure_get_capture_ptr(closure, 1); - if signal_ptr != 0 && callback_ptr != 0 { - js_abort_signal_remove_listener( - signal_ptr as *mut u8, - abort_event_value(), - nanbox_pointer_bits(callback_ptr), - ); - } - } - undefined_value() -} - -/// `events.addAbortListener(signal, listener)` — attach `listener` to the -/// AbortSignal's "abort" event and return a `Disposable`-shaped plain object. -/// -/// # Safety -/// -/// `signal` and `listener` are NaN-boxed JS values, matching codegen's -/// module-helper ABI. -#[no_mangle] -pub unsafe extern "C" fn js_events_add_abort_listener(signal: f64, listener: f64) -> i64 { - let signal = validate_abort_signal_arg(signal, "signal"); - let signal_ptr = object_ptr_from_value(signal).unwrap_or_else(|| { - throw_invalid_arg_type(&invalid_instance_arg_message( - "signal", - "AbortSignal", - signal, - )) - }); - let callback_ptr = validate_event_listener(listener.to_bits() as i64); - - let listener_val = nanbox_pointer_bits(callback_ptr); - js_abort_signal_add_listener(signal_ptr as *mut u8, abort_event_value(), listener_val); - - let dispose_closure = js_closure_alloc(events_abort_listener_dispose as *const u8, 2); - js_closure_set_capture_ptr(dispose_closure, 0, signal_ptr as i64); - js_closure_set_capture_ptr(dispose_closure, 1, callback_ptr); - let dispose_val = nanbox_pointer_bits(dispose_closure as i64); - - let disposable = js_object_alloc(0, 0); - let disposable_val = nanbox_pointer_bits(disposable as i64); - let dispose_key = b"@@__perry_wk_dispose"; - let dispose_key_ptr = js_string_from_bytes(dispose_key.as_ptr(), dispose_key.len() as u32); - let dispose_key_val = f64::from_bits(nanbox_string_bits(dispose_key_ptr)); - let dispose_sym_val = js_symbol_for(dispose_key_val); - js_object_set_symbol_property(disposable_val, dispose_sym_val, dispose_val); - disposable as i64 -} - #[cfg(test)] mod tests; diff --git a/crates/perry-ext-events/src/module_iterators.rs b/crates/perry-ext-events/src/module_iterators.rs index d00b8595b5..e7584299df 100644 --- a/crates/perry-ext-events/src/module_iterators.rs +++ b/crates/perry-ext-events/src/module_iterators.rs @@ -132,32 +132,156 @@ pub(super) unsafe fn first_rest_arg_or_undefined(rest: f64) -> f64 { } } -/// Queue listener for `events.on(...)` — captures the queue array in -/// slot 0 and pushes `[arg]` onto it for each emitted event. The -/// `for await (... of iter)` loop pulls items off the array as the -/// stream produces them. +// `events.on()` state lives in a GC-traced Array captured by the listener and +// iterator closures. Keeping all JS pointers inside that array means the +// extension's existing listener scanner is sufficient across moving GC. +const EVENTS_ON_BUFFER: u32 = 0; +const EVENTS_ON_PENDING: u32 = 1; +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(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)); + let pending = js_array_alloc(0); + let pending_root = scope.root_nanbox(nanbox_pointer_bits(pending as i64)); + let state_ptr = || (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader; + let _ = js_array_push_f64(state_ptr(), buffer_root.get()); + let _ = js_array_push_f64(state_ptr(), pending_root.get()); + let _ = js_array_push_f64(state_ptr(), f64::from_bits(0x7FFC_0000_0000_0003)); + 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() +} + +unsafe fn events_on_state_array(state: *mut ArrayHeader, index: u32) -> *mut ArrayHeader { + let value = f64::from_bits(js_array_get(state, index).bits()); + (value.to_bits() & POINTER_MASK) as *mut ArrayHeader +} + +unsafe fn events_on_state_set(state: *mut ArrayHeader, index: u32, value: f64) { + js_array_set(state, index, JsValue::from_bits(value.to_bits())); +} + +pub(super) unsafe fn events_on_state_set_target( + state: *mut ArrayHeader, + target: f64, + listener: *mut RawClosureHeader, + event_name: f64, + target_kind: u32, +) { + 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 { + let scope = TransientRootScope::enter(); + let value_root = scope.root_nanbox(value); + let packed = b"value\0done\0"; + let object = unsafe { + js_object_alloc_with_shape( + EVENTS_ON_ITER_SHAPE_ID, + 2, + packed.as_ptr(), + packed.len() as u32, + ) + }; + let object_root = scope.root_nanbox(nanbox_pointer_bits(object as i64)); + unsafe { + let current = (object_root.get().to_bits() & POINTER_MASK) as *mut ObjectHeader; + js_object_set_field(current, 0, JsValue::from_bits(value_root.get().to_bits())); + js_object_set_field(current, 1, JsValue::from_bool(done)); + } + object_root.get() +} + +fn events_on_resolved(value: f64, done: bool) -> f64 { + unsafe { + let scope = TransientRootScope::enter(); + let result = scope.root_nanbox(events_on_iter_result(value, done)); + let promise = js_promise_new(); + let promise_root = scope.root_addr(promise as i64); + js_promise_resolve(promise_root.get() as *mut Promise, result.get()); + nanbox_pointer_bits(promise_root.get()) + } +} + +fn events_on_finish_pending(state: *mut ArrayHeader, reason: Option) { + unsafe { + let pending = events_on_state_array(state, EVENTS_ON_PENDING); + if pending.is_null() { + return; + } + while js_array_length(pending) > 0 { + let promise = (js_array_shift_f64(pending).to_bits() & POINTER_MASK) as *mut Promise; + if promise.is_null() { + continue; + } + if let Some(reason) = reason { + js_promise_reject(promise, reason); + } else { + js_promise_resolve(promise, events_on_iter_result(undefined_value(), true)); + } + } + } +} + +/// Queue listener for `events.on(...)`. Resolve an already-blocked `next()` +/// immediately, otherwise retain the argument tuple in FIFO order. pub(super) extern "C" fn events_on_queue_listener( closure: *const RawClosureHeader, arg0: f64, ) -> f64 { unsafe { - let queue = js_closure_get_capture_ptr(closure, 0) as *mut ArrayHeader; - let abort_promise = js_closure_get_capture_ptr(closure, 1) as *mut Promise; - if !queue.is_null() { + let state = js_closure_get_capture_ptr(closure, 0) as *mut ArrayHeader; + 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_val = nanbox_pointer_bits(args as i64); - if abort_promise.is_null() { - let _ = js_array_push_f64(queue, args_val); + let args_root = scope.root_nanbox(nanbox_pointer_bits(args as i64)); + let current_state = (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader; + let pending = events_on_state_array(current_state, EVENTS_ON_PENDING); + if !pending.is_null() && js_array_length(pending) > 0 { + let promise = + (js_array_shift_f64(pending).to_bits() & POINTER_MASK) as *mut Promise; + if !promise.is_null() { + let promise_root = scope.root_addr(promise as i64); + let result = scope.root_nanbox(events_on_iter_result(args_root.get(), false)); + js_promise_resolve(promise_root.get() as *mut Promise, result.get()); + } } else { - let abort_val = nanbox_pointer_bits(abort_promise as i64); - let len = (*queue).length; - if len == 0 { - let _ = js_array_push_f64(queue, args_val); - let _ = js_array_push_f64(queue, abort_val); - } else { - js_array_set(queue, len - 1, JsValue::from_bits(args_val.to_bits())); - let _ = js_array_push_f64(queue, abort_val); + let current_state = (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader; + let buffer = events_on_state_array(current_state, EVENTS_ON_BUFFER); + if !buffer.is_null() { + let _ = js_array_push_f64(buffer, args_root.get()); } } } @@ -165,12 +289,177 @@ pub(super) extern "C" fn events_on_queue_listener( f64::from_bits(TAG_UNDEFINED_F64_BITS) } +extern "C" fn events_on_next(closure: *const RawClosureHeader) -> f64 { + unsafe { + let state = js_closure_get_capture_ptr(closure, 0) as *mut ArrayHeader; + if state.is_null() { + return events_on_resolved(undefined_value(), true); + } + let buffer = events_on_state_array(state, EVENTS_ON_BUFFER); + if !buffer.is_null() && js_array_length(buffer) > 0 { + return events_on_resolved(js_array_shift_f64(buffer), false); + } + let abort = f64::from_bits(js_array_get(state, EVENTS_ON_ABORT).bits()); + if abort.to_bits() != TAG_UNDEFINED_F64_BITS { + let promise = js_promise_new(); + js_promise_reject(promise, abort); + return nanbox_pointer_bits(promise as i64); + } + let done = f64::from_bits(js_array_get(state, EVENTS_ON_DONE).bits()); + if done.to_bits() == TAG_TRUE_F64_BITS { + return events_on_resolved(undefined_value(), true); + } + let promise = js_promise_new(); + let pending = events_on_state_array(state, EVENTS_ON_PENDING); + if !pending.is_null() { + let _ = js_array_push_f64(pending, nanbox_pointer_bits(promise as i64)); + } + nanbox_pointer_bits(promise as i64) + } +} + +extern "C" fn events_on_return(closure: *const RawClosureHeader) -> f64 { + unsafe { + let state = js_closure_get_capture_ptr(closure, 0) as *mut ArrayHeader; + if state.is_null() { + return events_on_resolved(undefined_value(), true); + } + events_on_state_set(state, EVENTS_ON_DONE, f64::from_bits(TAG_TRUE_F64_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()); + 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); + events_on_resolved(undefined_value(), true) + } +} + +extern "C" fn events_on_iterator_self(closure: *const RawClosureHeader) -> f64 { + unsafe { js_closure_get_capture_f64(closure, 0) } +} + +extern "C" fn events_on_async_iterator(closure: *const RawClosureHeader) -> f64 { + unsafe { + let state = js_closure_get_capture_ptr(closure, 0) as *mut ArrayHeader; + let scope = TransientRootScope::enter(); + let state_root = scope.root_nanbox(nanbox_pointer_bits(state as i64)); + let packed = b"next\0return\0"; + let object = js_object_alloc_with_shape( + EVENTS_ON_ITER_SHAPE_ID + 1, + 2, + packed.as_ptr(), + packed.len() as u32, + ); + let object_root = scope.root_nanbox(nanbox_pointer_bits(object as i64)); + let next = js_closure_alloc(events_on_next as *const u8, 1); + js_closure_set_capture_ptr(next, 0, (state_root.get().to_bits() & POINTER_MASK) as i64); + let next_root = scope.root_addr(next as i64); + js_object_set_field( + (object_root.get().to_bits() & POINTER_MASK) as *mut ObjectHeader, + 0, + JsValue::from_object_ptr(next_root.get() as *mut u8), + ); + let return_fn = js_closure_alloc(events_on_return as *const u8, 1); + js_closure_set_capture_ptr( + return_fn, + 0, + (state_root.get().to_bits() & POINTER_MASK) as i64, + ); + let return_root = scope.root_addr(return_fn as i64); + js_object_set_field( + (object_root.get().to_bits() & POINTER_MASK) as *mut ObjectHeader, + 1, + JsValue::from_object_ptr(return_root.get() as *mut u8), + ); + + let iterator = object_root.get(); + let iterator_root = scope.root_nanbox(iterator); + let symbol = js_symbol_well_known_async_iterator(); + let self_fn = js_closure_alloc(events_on_iterator_self as *const u8, 1); + js_closure_set_capture_f64(self_fn, 0, iterator_root.get()); + js_object_set_symbol_property( + iterator_root.get(), + symbol, + nanbox_pointer_bits(self_fn as i64), + ); + iterator_root.get() + } +} + +pub(super) unsafe fn events_on_install_async_iterator( + queue: *mut ArrayHeader, + state: *mut ArrayHeader, +) { + let scope = TransientRootScope::enter(); + let queue_root = scope.root_nanbox(nanbox_pointer_bits(queue as i64)); + let state_root = scope.root_nanbox(nanbox_pointer_bits(state as i64)); + js_register_closure_arity(events_on_next as *const u8, 0); + js_register_closure_arity(events_on_return as *const u8, 0); + js_register_closure_arity(events_on_iterator_self as *const u8, 0); + js_register_closure_arity(events_on_async_iterator as *const u8, 0); + let closure = js_closure_alloc(events_on_async_iterator as *const u8, 1); + js_closure_set_capture_ptr( + closure, + 0, + (state_root.get().to_bits() & POINTER_MASK) as i64, + ); + js_object_set_symbol_property( + queue_root.get(), + js_symbol_well_known_async_iterator(), + nanbox_pointer_bits(closure as i64), + ); +} + +/// A configured close event ends the iterator after already-buffered events. +pub(super) extern "C" fn events_on_close_listener(closure: *const RawClosureHeader) -> f64 { + unsafe { + let state = js_closure_get_capture_ptr(closure, 0) as *mut ArrayHeader; + if !state.is_null() { + events_on_state_set(state, EVENTS_ON_DONE, f64::from_bits(TAG_TRUE_F64_BITS)); + events_on_finish_pending(state, None); + } + } + undefined_value() +} + pub(super) extern "C" fn events_on_abort_listener(closure: *const RawClosureHeader) -> f64 { unsafe { let handle = js_closure_get_capture_ptr(closure, 0) as Handle; let data_listener = js_closure_get_capture_ptr(closure, 1); let signal_ptr = js_closure_get_capture_ptr(closure, 2) as *mut u8; - let abort_promise = js_closure_get_capture_ptr(closure, 3) as *mut Promise; + let state = js_closure_get_capture_ptr(closure, 3) as *mut ArrayHeader; let event_name_ptr = js_closure_get_capture_ptr(closure, 4) as *const StringHeader; if let Some(emitter) = get_event_emitter_mut(handle) { @@ -192,8 +481,11 @@ pub(super) extern "C" fn events_on_abort_listener(closure: *const RawClosureHead nanbox_pointer_bits(closure as i64), ); } - if !abort_promise.is_null() { - js_promise_reject(abort_promise, js_abort_error_value()); + if !state.is_null() { + let reason = js_abort_error_value(); + events_on_state_set(state, EVENTS_ON_ABORT, reason); + events_on_state_set(state, EVENTS_ON_DONE, f64::from_bits(TAG_TRUE_F64_BITS)); + events_on_finish_pending(state, Some(reason)); } } undefined_value() diff --git a/crates/perry-ext-events/src/module_on.rs b/crates/perry-ext-events/src/module_on.rs new file mode 100644 index 0000000000..a583696c80 --- /dev/null +++ b/crates/perry-ext-events/src/module_on.rs @@ -0,0 +1,219 @@ +use super::*; + +#[no_mangle] +pub unsafe extern "C" fn js_events_on( + target_value: f64, + event_name_ptr: *const StringHeader, + options: f64, +) -> *mut ArrayHeader { + ensure_gc_scanner_registered(); + let root_scope = TransientRootScope::enter(); + 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(); + let state_root = root_scope.root_nanbox(nanbox_pointer_bits(state as i64)); + events_on_install_async_iterator( + (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_root.get().to_bits() as i64) else { + return (queue_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader; + }; + 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()); + } + let listener = js_closure_alloc(events_on_queue_listener as *const u8, 1); + js_closure_set_capture_ptr( + listener, + 0, + (state_root.get().to_bits() & POINTER_MASK) as i64, + ); + let listener_root = root_scope.root_addr(listener as i64); + 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 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, + 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_root.get()); + let _ = call_net_socket_method(handle, "on", &[event, listener_value]); + } + (handle, handle as f64, EVENTS_ON_NET_HANDLE) + } + EventHelperTarget::Stream(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_root.get()); + let _ = js_node_stream_method_on(handle, event, listener_value); + } + (handle, handle as f64, EVENTS_ON_STREAM) + } + }; + events_on_state_set_target( + (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader, + 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 { + let close_array = (close.to_bits() & POINTER_MASK) as *mut ArrayHeader; + if !close_array.is_null() { + for index in 0..js_array_length(close_array) { + let close_value = f64::from_bits(js_array_get(close_array, index).bits()); + let Some(close_name) = event_name_from_bits(close_value.to_bits() as i64) + else { + continue; + }; + let close_ptr = + js_string_from_bytes(close_name.as_ptr(), close_name.len() as u32); + let close_listener = js_closure_alloc(events_on_close_listener as *const u8, 1); + js_closure_set_capture_ptr( + close_listener, + 0, + (state_root.get().to_bits() & POINTER_MASK) as i64, + ); + let close_listener_root = root_scope.root_addr(close_listener as i64); + match target { + EventHelperTarget::EventEmitter(emitter_handle) => { + if let Some(emitter) = get_event_emitter_mut(emitter_handle) { + emitter.add_listener( + emitter_handle, + &close_name, + close_listener_root.get(), + true, + false, + ); + } + } + EventHelperTarget::EventTarget(event_target) => { + js_event_target_add_event_listener( + event_target, + close_ptr, + close_listener_root.get(), + ); + } + EventHelperTarget::NetSocket(handle) + | EventHelperTarget::NativeHandle(handle) => { + let event = f64::from_bits(nanbox_string_bits(close_ptr)); + let callback = nanbox_pointer_bits(close_listener_root.get()); + let _ = call_net_socket_method(handle, "once", &[event, callback]); + } + EventHelperTarget::Stream(handle) => { + let event = f64::from_bits(nanbox_string_bits(close_ptr)); + let callback = nanbox_pointer_bits(close_listener_root.get()); + let _ = js_node_stream_method_once(handle, event, callback); + } + } + } + } + } + } + if let Some(signal) = signal { + if let Some(signal_ptr) = object_ptr_from_value(signal) { + let abort_listener = js_closure_alloc(events_on_abort_listener as *const u8, 5); + js_closure_set_capture_ptr(abort_listener, 0, handle); + js_closure_set_capture_ptr(abort_listener, 1, listener_root.get()); + js_closure_set_capture_ptr(abort_listener, 2, signal_ptr as i64); + js_closure_set_capture_ptr( + abort_listener, + 3, + (state_root.get().to_bits() & POINTER_MASK) as i64, + ); + js_closure_set_capture_ptr(abort_listener, 4, event_name_ptr as i64); + js_abort_signal_add_listener( + signal_ptr as *mut u8, + abort_event_value(), + nanbox_pointer_bits(abort_listener as i64), + ); + } + } + (queue_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader +} + +extern "C" fn events_abort_listener_dispose(closure: *const RawClosureHeader) -> f64 { + unsafe { + let signal_ptr = js_closure_get_capture_ptr(closure, 0); + let callback_ptr = js_closure_get_capture_ptr(closure, 1); + if signal_ptr != 0 && callback_ptr != 0 { + js_abort_signal_remove_listener( + signal_ptr as *mut u8, + abort_event_value(), + nanbox_pointer_bits(callback_ptr), + ); + } + } + undefined_value() +} + +#[no_mangle] +pub unsafe extern "C" fn js_events_add_abort_listener(signal: f64, listener: f64) -> i64 { + let root_scope = TransientRootScope::enter(); + let signal_root = root_scope.root_nanbox(signal); + let listener_root = root_scope.root_nanbox(listener); + let signal = validate_abort_signal_arg(signal_root.get(), "signal"); + let signal_ptr = object_ptr_from_value(signal_root.get()).unwrap_or_else(|| { + throw_invalid_arg_type(&invalid_instance_arg_message( + "signal", + "AbortSignal", + signal, + )) + }); + let callback_ptr = validate_event_listener(listener_root.get().to_bits() as i64); + js_abort_signal_add_listener( + signal_ptr as *mut u8, + abort_event_value(), + nanbox_pointer_bits(callback_ptr), + ); + let dispose_closure = js_closure_alloc(events_abort_listener_dispose as *const u8, 2); + let dispose_closure_root = root_scope.root_addr(dispose_closure as i64); + let signal_ptr = object_ptr_from_value(signal_root.get()) + .expect("validated AbortSignal remained a rooted object"); + let callback_ptr = validate_event_listener(listener_root.get().to_bits() as i64); + js_closure_set_capture_ptr( + dispose_closure_root.get() as *mut RawClosureHeader, + 0, + signal_ptr as i64, + ); + js_closure_set_capture_ptr( + dispose_closure_root.get() as *mut RawClosureHeader, + 1, + callback_ptr, + ); + let disposable = js_object_alloc(0, 0); + let disposable_root = root_scope.root_addr(disposable as i64); + let dispose_key = b"@@__perry_wk_dispose"; + let dispose_key_ptr = js_string_from_bytes(dispose_key.as_ptr(), dispose_key.len() as u32); + let dispose_sym_val = js_symbol_for(f64::from_bits(nanbox_string_bits(dispose_key_ptr))); + js_object_set_symbol_property( + nanbox_pointer_bits(disposable_root.get()), + dispose_sym_val, + nanbox_pointer_bits(dispose_closure_root.get()), + ); + disposable_root.get() +} diff --git a/crates/perry-ext-events/src/target_helpers.rs b/crates/perry-ext-events/src/target_helpers.rs index 329e5ca885..f8fbcfe2a3 100644 --- a/crates/perry-ext-events/src/target_helpers.rs +++ b/crates/perry-ext-events/src/target_helpers.rs @@ -1,5 +1,6 @@ use super::*; +#[derive(Clone, Copy)] pub(super) enum EventHelperTarget { EventEmitter(Handle), EventTarget(*mut u8), diff --git a/crates/perry-ext-events/src/tests.rs b/crates/perry-ext-events/src/tests.rs index 74c0a33c9b..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, ()>, } @@ -15,9 +16,18 @@ impl GcTestGuard { let lock = GC_TEST_LOCK .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. 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, + } } } @@ -25,6 +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); + perry_runtime::gc::js_gc_force_evacuation_test_override(self.previous_force_evacuation); } } @@ -181,6 +192,7 @@ fn gc_mutable_scanner_rewrites_listener_and_pending_promise_roots() { max_listeners: 10.0, capture_rejections: false, domain_handle: None, + async_resource_handle: 0, }); let _ = perry_runtime::gc::gc_collect_minor(); 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 a17307bd91..1e357215ab 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -100,7 +100,9 @@ use response_headers::build_response_headers_object; mod request_headers; use request_headers::headers_from_options; +mod pending_dispatch; mod root_scanner; +pub use pending_dispatch::js_http_process_pending; use root_scanner::scan_http_roots; use bytes::Bytes; @@ -423,6 +425,7 @@ fn map_to_js_object(map: &HashMap) -> f64 { // ------------------------------------------------------------------ pub struct ClientRequestHandle { + async_id: u64, method: String, url: String, headers: HashMap, @@ -667,7 +670,11 @@ fn make_request_handle( agent_handle: Handle, agent_key: String, ) -> Handle { + let async_id = unsafe { + js_async_hooks_provider_init(b"HTTPCLIENTREQUEST".as_ptr(), b"HTTPCLIENTREQUEST".len()) + }; let handle = register_handle(ClientRequestHandle { + async_id, method, url, headers, @@ -744,6 +751,51 @@ fn make_request_handle( handle } +extern "C" { + fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64; + 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 { + match event { + PendingHttpEvent::Socket { request_handle } + | PendingHttpEvent::SignalAbort { request_handle } + | PendingHttpEvent::Response { request_handle, .. } + | PendingHttpEvent::ResponseHead { request_handle, .. } + | PendingHttpEvent::ResponseChunk { request_handle, .. } + | PendingHttpEvent::ResponseEnd { request_handle } + | PendingHttpEvent::Error { request_handle, .. } + | PendingHttpEvent::TransportError { request_handle, .. } + | PendingHttpEvent::Timeout { request_handle } + | PendingHttpEvent::Abort { request_handle } + | PendingHttpEvent::Flushed { request_handle } + | PendingHttpEvent::Continue { request_handle } + | PendingHttpEvent::DeferredArmContinue { request_handle } => *request_handle, + PendingHttpEvent::AgentIdleExpire { .. } => 0, + } +} + +fn terminal_http_event(event: &PendingHttpEvent) -> bool { + matches!( + event, + PendingHttpEvent::SignalAbort { .. } + | PendingHttpEvent::Response { .. } + | PendingHttpEvent::ResponseEnd { .. } + | PendingHttpEvent::Error { .. } + | PendingHttpEvent::TransportError { .. } + | PendingHttpEvent::Abort { .. } + ) +} + /// Parse the client-side TLS options (#4906) off a request options value /// and store them on the freshly-built request handle. A no-op for /// string-URL requests / plain http (parse yields the default). @@ -1735,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) { @@ -1833,142 +1923,6 @@ pub extern "C" fn js_http_has_pending() -> i32 { .unwrap_or(0) } -/// Drain the pending HTTP-event queue and fire user callbacks. Called -/// from codegen's event-loop tick. Returns count of events drained. -#[no_mangle] -pub unsafe extern "C" fn js_http_process_pending() -> i32 { - // Process events ONE AT A TIME, re-reading the shared queue each iteration - // rather than draining the whole batch into a local Vec up front. - // - // Why (#5783 follow-up): a response handler may RE-ENTER the event loop — - // e.g. a `ResponseHead` invokes an async response callback that drives a - // `for await` / `.toArray()` over a `res.pipe(PassThrough())` body. That - // consumer's `await` block-waits, pumping the loop (which re-enters this - // function), and its resolution depends on the body arriving via the LATER - // `ResponseChunk`/`ResponseEnd` events of this same batch. If those were - // already drained into a local Vec, the re-entrant pump would find an empty - // `HTTP_PENDING_EVENTS` and the consumer would deadlock (empty body / hang). - // Keeping unprocessed events in the shared queue lets the re-entrant drain - // deliver them. FIFO `remove(0)` preserves event order; each event is taken - // by exactly one (outer or re-entrant) frame, so there is no double-dispatch. - let mut count = 0i32; - loop { - let ev = match HTTP_PENDING_EVENTS.lock() { - Ok(mut q) => { - if q.is_empty() { - None - } else { - Some(q.remove(0)) - } - } - Err(_) => return count, - }; - let Some(ev) = ev else { - break; - }; - count += 1; - match ev { - PendingHttpEvent::Socket { request_handle } => { - client_events::fire_request_socket_event(request_handle); - } - PendingHttpEvent::SignalAbort { request_handle } => { - client_abort::handle_request_signal_abort(request_handle); - } - PendingHttpEvent::AgentIdleExpire { - agent_handle, - key, - socket, - generation, - } => { - agent::expire_free_socket(agent_handle, &key, socket, generation); - } - PendingHttpEvent::Response { - request_handle, - status, - status_message, - headers, - trailers, - body, - } => { - client_events::handle_response_event( - request_handle, - status, - status_message, - headers, - trailers, - body, - ); - } - PendingHttpEvent::ResponseHead { - request_handle, - status, - status_message, - headers, - } => { - client_events::handle_response_head_event( - request_handle, - status, - status_message, - headers, - ); - } - PendingHttpEvent::ResponseChunk { - request_handle, - chunk, - } => { - client_events::handle_response_chunk_event(request_handle, chunk); - } - PendingHttpEvent::ResponseEnd { request_handle } => { - client_events::handle_response_end_event(request_handle); - } - PendingHttpEvent::Error { - request_handle, - error_message, - } => { - client_events::handle_error_event(request_handle, &error_message); - } - PendingHttpEvent::TransportError { - request_handle, - message, - code, - syscall, - errno, - } => { - client_events::handle_transport_error_event( - request_handle, - &message, - &code, - &syscall, - errno, - ); - } - PendingHttpEvent::Timeout { request_handle } => { - client_events::handle_timeout_event(request_handle); - } - PendingHttpEvent::Abort { request_handle } => { - client_events::fire_request_event_listeners(request_handle, "abort"); - client_events::fire_request_close_once(request_handle); - finish_agent_request(request_handle, false); - } - PendingHttpEvent::Flushed { request_handle } => { - client_events::handle_flushed_event(request_handle); - } - PendingHttpEvent::Continue { request_handle } => { - // #5080 — the server sent an interim `100 Continue`; fire the - // request's `'continue'` listeners (the canonical handler then - // sends the withheld body via `req.end(...)`). - client_events::fire_request_event_listeners(request_handle, "continue"); - } - PendingHttpEvent::DeferredArmContinue { request_handle } => { - // #5080 — next-tick arming (see the enum variant docs). - continue_client::arm_expect_continue(request_handle); - } - } - } - - count -} - // ------------------------------------------------------------------ // Tests // ------------------------------------------------------------------ diff --git a/crates/perry-ext-http/src/pending_dispatch.rs b/crates/perry-ext-http/src/pending_dispatch.rs new file mode 100644 index 0000000000..c2a1bb24cd --- /dev/null +++ b/crates/perry-ext-http/src/pending_dispatch.rs @@ -0,0 +1,120 @@ +use super::*; + +/// Drain the pending HTTP-event queue and fire user callbacks. Events remain +/// in the shared queue until selected so re-entrant event-loop pumps can make +/// progress on later response chunks (#5783). +#[no_mangle] +pub unsafe extern "C" fn js_http_process_pending() -> i32 { + let mut count = 0i32; + loop { + let ev = match HTTP_PENDING_EVENTS.lock() { + Ok(mut q) => { + if q.is_empty() { + None + } else { + Some(q.remove(0)) + } + } + Err(_) => return count, + }; + let Some(ev) = ev else { break }; + count += 1; + let request_handle = pending_request_handle(&ev); + let terminal = terminal_http_event(&ev); + let async_id = with_handle_mut::(request_handle, |request| { + request.async_id + }) + .unwrap_or(0); + if async_id != 0 { + js_async_hooks_provider_enter(async_id); + } + match ev { + PendingHttpEvent::Socket { request_handle } => { + client_events::fire_request_socket_event(request_handle); + } + PendingHttpEvent::SignalAbort { request_handle } => { + client_abort::handle_request_signal_abort(request_handle); + } + PendingHttpEvent::AgentIdleExpire { + agent_handle, + key, + socket, + generation, + } => agent::expire_free_socket(agent_handle, &key, socket, generation), + PendingHttpEvent::Response { + request_handle, + status, + status_message, + headers, + trailers, + body, + } => client_events::handle_response_event( + request_handle, + status, + status_message, + headers, + trailers, + body, + ), + PendingHttpEvent::ResponseHead { + request_handle, + status, + status_message, + headers, + } => client_events::handle_response_head_event( + request_handle, + status, + status_message, + headers, + ), + PendingHttpEvent::ResponseChunk { + request_handle, + chunk, + } => client_events::handle_response_chunk_event(request_handle, chunk), + PendingHttpEvent::ResponseEnd { request_handle } => { + client_events::handle_response_end_event(request_handle); + } + PendingHttpEvent::Error { + request_handle, + error_message, + } => client_events::handle_error_event(request_handle, &error_message), + PendingHttpEvent::TransportError { + request_handle, + message, + code, + syscall, + errno, + } => client_events::handle_transport_error_event( + request_handle, + &message, + &code, + &syscall, + errno, + ), + PendingHttpEvent::Timeout { request_handle } => { + client_events::handle_timeout_event(request_handle); + } + PendingHttpEvent::Abort { request_handle } => { + client_events::fire_request_event_listeners(request_handle, "abort"); + client_events::fire_request_close_once(request_handle); + finish_agent_request(request_handle, false); + } + PendingHttpEvent::Flushed { request_handle } => { + client_events::handle_flushed_event(request_handle); + } + PendingHttpEvent::Continue { request_handle } => { + client_events::fire_request_event_listeners(request_handle, "continue"); + } + PendingHttpEvent::DeferredArmContinue { request_handle } => { + continue_client::arm_expect_continue(request_handle); + } + } + if async_id != 0 { + js_async_hooks_provider_leave(async_id); + if terminal { + js_async_hooks_provider_destroy(async_id); + } + } + } + count +} 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 0aa207bd5f..6cb0a0212b 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -72,6 +72,7 @@ pub(crate) fn apply_accept_no_delay(stream: &tokio::net::TcpStream, no_delay: bo /// Backing struct for an `http.Server` JS-side handle. pub struct HttpServer { + pub async_id: u64, /// User's `(req, res) => ...` handler. Stored as raw `i64`; the /// GC root scanner pins it across malloc-triggered sweeps. pub handler: i64, @@ -168,6 +169,7 @@ impl HttpServer { /// (https / http2 / test fixtures). pub fn with_handler(handler: i64) -> Self { Self { + async_id: 0, handler, listeners: HashMap::new(), once_listeners: HashMap::new(), @@ -774,7 +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 (request_tx, request_rx) = mpsc::channel::(1024); let (upgrade_tx, upgrade_rx) = mpsc::channel::(256); let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); @@ -937,8 +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 @@ -1479,7 +1488,6 @@ pub extern "C" fn js_node_http_server_has_active() -> i32 { /// `(req, res) => res.end(...)` shape that the load-bearing #604 /// fixture uses works without this — the response oneshot fires /// synchronously from inside `js_node_http_res_end`. - #[no_mangle] pub extern "C" fn js_node_http_server_process_pending() -> i32 { let mut count = 0i32; @@ -1667,6 +1675,15 @@ pub(crate) fn try_recv_pending_nonblocking(server_handle: i64) -> Option 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 @@ -46,7 +69,7 @@ where T: Send + Sync + 'static, F: FnOnce(&mut T) -> &mut HttpServer, { - let cbs: Vec = match get_handle_mut::(server_handle) { + let (cbs, async_id): (Vec, u64) = match get_handle_mut::(server_handle) { Some(t) => { let s = base_of(t); if !std::mem::take(&mut s.pending_listening_emit) { @@ -64,30 +87,27 @@ where } } } - snapshot + (snapshot, s.async_id) } None => return 0, }; 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; - } + 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 @@ -95,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) { @@ -110,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-http/src/tests.rs b/crates/perry-ext-http/src/tests.rs index 797f796db2..34b9a42948 100644 --- a/crates/perry-ext-http/src/tests.rs +++ b/crates/perry-ext-http/src/tests.rs @@ -88,6 +88,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { }], ); let request_handle = register_handle(ClientRequestHandle { + async_id: 0, method: "GET".to_string(), url: "http://localhost/".to_string(), headers: HashMap::new(), @@ -175,6 +176,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { /// no live codegen — only the handle registry the other tests already use. fn drain_streamed_body(chunks: &[&[u8]]) -> Vec { let request_handle = register_handle(ClientRequestHandle { + async_id: 0, method: "GET".to_string(), url: "http://localhost/".to_string(), headers: HashMap::new(), @@ -346,6 +348,7 @@ fn dispatch_request_stays_visible_to_exit_gate_until_response_queued() { }); let request_handle = register_handle(ClientRequestHandle { + async_id: 0, method: "GET".to_string(), url: format!("http://127.0.0.1:{port}/"), headers: HashMap::new(), diff --git a/crates/perry-ext-net/src/adopt.rs b/crates/perry-ext-net/src/adopt.rs index e2f5a39952..d3d8f331ad 100644 --- a/crates/perry-ext-net/src/adopt.rs +++ b/crates/perry-ext-net/src/adopt.rs @@ -41,6 +41,9 @@ pub fn adopt_upgraded_tcp_stream(stream: tokio::net::TcpStream) -> i64 { statics::sockets().lock().unwrap().insert( id, SocketState { + tcp_async_id: 0, + connect_async_id: 0, + shutdown_async_id: 0, cmd_tx: tx, pending_rx: None, is_open: true, diff --git a/crates/perry-ext-net/src/dispatch.rs b/crates/perry-ext-net/src/dispatch.rs index 76afc8fbe8..6789e01d8d 100644 --- a/crates/perry-ext-net/src/dispatch.rs +++ b/crates/perry-ext-net/src/dispatch.rs @@ -204,14 +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); + 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); + 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 new file mode 100644 index 0000000000..35b7524cc6 --- /dev/null +++ b/crates/perry-ext-net/src/gc_roots.rs @@ -0,0 +1,71 @@ +//! GC-root registration and scanning for native net handles. + +use super::*; + +static NET_GC_REGISTERED: std::sync::Once = std::sync::Once::new(); + +extern "C" { + fn js_register_net_socket_handle_probe(f: unsafe extern "C" fn(i64) -> bool); +} + +unsafe extern "C" fn ext_net_socket_handle_probe(handle: i64) -> bool { + is_net_socket_handle(handle) +} + +/// Register the net GC root scanner exactly once. Safe to call from any +/// `js_net_*` entry point on the main thread. +pub(crate) fn ensure_gc_scanner_registered() { + NET_GC_REGISTERED.call_once(|| { + gc_register_mutable_root_scanner_named("perry-ext-net", scan_net_roots); + unsafe { + js_register_net_socket_handle_probe(ext_net_socket_handle_probe); + } + // #2154 — publish the raw-consumer vtable for perry-ext-http (runs on + // the first net FFI entry, before http could reference a socket). + raw_bridge::register(); + }); +} + +/// GC root scanner for net.Socket event listener closures. +/// +/// Without this, any GC cycle between `.on()` and the next dispatch would +/// sweep the closure; the next `closure.call*()` would dereference freed +/// memory. Same pattern as perry-stdlib's net mod and perry-ext-events. +pub(crate) fn scan_net_roots(visitor: &mut GcRootVisitor<'_>) { + if let Ok(mut listeners) = statics::listeners().lock() { + for per_socket in listeners.values_mut() { + for cb_vec in per_socket.values_mut() { + for cb in cb_vec.iter_mut() { + visitor.visit_i64_slot(cb); + } + } + } + } + // `once_flags()` keys membership by the closure's ADDRESS BITS. The + // canonical copy in `listeners()` above keeps the closure alive and is + // rewritten when the copying GC moves it — but a `HashSet` element + // cannot be rewritten in place, so without this rebuild the set still + // holds the OLD address after evacuation: the once-membership test in + // `lifecycle.rs` then misses, the listener is never auto-removed, and a + // "once" callback fires on every subsequent event. Drain, forward each + // element through the visitor, and reinsert under the new identity. + if let Ok(mut once) = statics::once_flags().lock() { + for per_handle in once.values_mut() { + for set in per_handle.values_mut() { + let old: Vec = set.drain().collect(); + for mut cb in old { + visitor.visit_i64_slot(&mut cb); + set.insert(cb); + } + } + } + } + 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/handle_exports.rs b/crates/perry-ext-net/src/handle_exports.rs new file mode 100644 index 0000000000..4c8612da70 --- /dev/null +++ b/crates/perry-ext-net/src/handle_exports.rs @@ -0,0 +1,87 @@ +use super::*; + +pub(super) fn listeners_for(id: i64, event: &str) -> Vec { + statics::listeners() + .lock() + .unwrap() + .get(&id) + .and_then(|m| m.get(event).cloned()) + .unwrap_or_default() +} + +#[no_mangle] +pub extern "C" fn js_net_has_pending() -> i32 { + server_state::has_active_handles() as i32 +} + +pub fn is_net_socket_handle(handle: i64) -> bool { + statics::sockets().lock().unwrap().contains_key(&handle) +} + +pub fn is_net_server_handle(handle: i64) -> bool { + statics::servers().lock().unwrap().contains_key(&handle) +} + +#[no_mangle] +pub extern "C" fn js_net_server_listening(handle: i64) -> i32 { + statics::servers() + .lock() + .ok() + .and_then(|servers| { + servers + .get(&handle) + .map(|server| i32::from(server.listening)) + }) + .unwrap_or(0) +} + +/// `server.on(event, cb)` — register a server-level listener for +/// `'connection'`, `'listening'`, `'close'`, or `'error'`. +/// +/// # Safety +/// +/// `event_ptr` must be null or a Perry-runtime `StringHeader`. `cb` +/// is a raw `*const ClosureHeader` cast to `i64`. +#[no_mangle] +pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) { + ensure_gc_scanner_registered(); + let event = match string_from_header_i64(event_ptr) { + Some(e) => e, + None => return, + }; + let mut listeners = statics::listeners().lock().unwrap(); + let entry = listeners.entry(handle).or_default(); + entry.entry(event).or_default().push(cb); +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) { + js_net_socket_on(handle, event_ptr, cb) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { + js_net_socket_once(handle, event_ptr, cb) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_remove_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + js_net_socket_remove_listener(handle, event_ptr, cb) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_remove_all_listeners( + handle: i64, + event_ptr: i64, +) -> i64 { + js_net_socket_remove_all_listeners(handle, event_ptr) +} + +#[no_mangle] +pub extern "C" fn js_ext_net_is_server_handle(handle: i64) -> i32 { + i32::from(is_net_server_handle(handle)) +} diff --git a/crates/perry-ext-net/src/ipc.rs b/crates/perry-ext-net/src/ipc.rs index a13eb49a53..b501471f3a 100644 --- a/crates/perry-ext-net/src/ipc.rs +++ b/crates/perry-ext-net/src/ipc.rs @@ -32,6 +32,9 @@ fn allocate_socket() -> (i64, mpsc::UnboundedReceiver) { statics::sockets().lock().unwrap().insert( id, SocketState { + tcp_async_id: 0, + connect_async_id: 0, + shutdown_async_id: 0, cmd_tx: tx, pending_rx: None, is_open: false, @@ -99,6 +102,9 @@ pub(crate) fn register_accepted_transport( statics::sockets().lock().unwrap().insert( socket_id, SocketState { + tcp_async_id: 0, + connect_async_id: 0, + shutdown_async_id: 0, cmd_tx: tx, pending_rx: None, is_open: true, diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 54ae80a332..98ee742735 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -36,11 +36,10 @@ 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; -use std::pin::Pin; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -56,7 +55,7 @@ mod ip; // `BytesMut` per read. See `buffer_pool.rs` for the rationale. mod buffer_pool; mod tls; -pub use tls::js_tls_connect; +pub use tls::{js_ext_tls_connect, js_tls_connect}; // #2131 — lifecycle / EventEmitter surface for `net.Socket` + `net.Server` // (once / off / removeAllListeners / listenerCount / eventNames / // resetAndDestroy, plus `socket.address()`). Re-exports keep the @@ -71,15 +70,24 @@ mod handle_ids; pub(crate) use handle_ids::{next_id, next_id_or_throw}; mod dispatch; mod dispatch_custody; +mod gc_roots; mod ipc; +pub(crate) use gc_roots::ensure_gc_scanner_registered; mod socket_emit; pub use socket_emit::{ js_ext_net_register_http_agent_socket_event_hook, js_ext_net_set_http_agent_phase, js_ext_net_socket_emit, js_ext_net_socket_emit_abort_error, }; +mod task_spawn; +use task_spawn::spawn_socket_runner; // #2154 — raw-consumer bridge so perry-ext-http can drive an HTTP exchange // over a socket produced by `agent.createConnection` (split out for the gate). +mod provider_lifecycle; mod raw_bridge; +use provider_lifecycle::{ + event_provider_id, init_provider, init_provider_with_trigger, prepare_event_provider, + ProviderScope, +}; use raw_bridge::RawReadState; // #2013 — chainable option-setter no-ops + Node arg-validation bridge to // perry-runtime (split out to keep lib.rs under the 2000-line gate). The @@ -222,6 +230,7 @@ pub(crate) mod statics { /// reusing the socket listener map keeps the GC scanner walk single- /// pass instead of needing a second per-server scanner. pub(crate) struct ServerState { + pub async_id: u64, /// Set by `.listen()`, dropped by `.close()`. Send on this channel /// to break the accept loop's `tokio::select!`. pub shutdown_tx: Option>, @@ -238,70 +247,10 @@ pub(crate) struct ServerState { pub drop_max_connection: Option, } -static NET_GC_REGISTERED: std::sync::Once = std::sync::Once::new(); - -extern "C" { - fn js_register_net_socket_handle_probe(f: unsafe extern "C" fn(i64) -> bool); -} - -unsafe extern "C" fn ext_net_socket_handle_probe(handle: i64) -> bool { - is_net_socket_handle(handle) -} - -/// Register the net GC root scanner exactly once. Safe to call from any -/// `js_net_*` entry point on the main thread. -pub(crate) fn ensure_gc_scanner_registered() { - NET_GC_REGISTERED.call_once(|| { - gc_register_mutable_root_scanner_named("perry-ext-net", scan_net_roots); - unsafe { - js_register_net_socket_handle_probe(ext_net_socket_handle_probe); - } - // #2154 — publish the raw-consumer vtable for perry-ext-http (runs on - // the first net FFI entry, before http could reference a socket). - raw_bridge::register(); - }); -} - -/// GC root scanner for net.Socket event listener closures. -/// -/// Without this, any GC cycle between `.on()` and the next dispatch would -/// sweep the closure; the next `closure.call*()` would dereference freed -/// memory. Same pattern as perry-stdlib's net mod and perry-ext-events. -fn scan_net_roots(visitor: &mut GcRootVisitor<'_>) { - if let Ok(mut listeners) = statics::listeners().lock() { - for per_socket in listeners.values_mut() { - for cb_vec in per_socket.values_mut() { - for cb in cb_vec.iter_mut() { - visitor.visit_i64_slot(cb); - } - } - } - } - // `once_flags()` keys membership by the closure's ADDRESS BITS. The - // canonical copy in `listeners()` above keeps the closure alive and is - // rewritten when the copying GC moves it — but a `HashSet` element - // cannot be rewritten in place, so without this rebuild the set still - // holds the OLD address after evacuation: the once-membership test in - // `lifecycle.rs` then misses, the listener is never auto-removed, and a - // "once" callback fires on every subsequent event. Drain, forward each - // element through the visitor, and reinsert under the new identity. - if let Ok(mut once) = statics::once_flags().lock() { - for per_handle in once.values_mut() { - for set in per_handle.values_mut() { - let old: Vec = set.drain().collect(); - for mut cb in old { - visitor.visit_i64_slot(&mut cb); - set.insert(cb); - } - } - } - } - // #8259 — the pump's in-flight dispatch frames (snapshotted callbacks + - // parked payloads), which the table walks above cannot see. - dispatch_custody::scan(visitor); -} - pub(crate) struct SocketState { + pub(crate) tcp_async_id: u64, + pub(crate) connect_async_id: u64, + pub(crate) shutdown_async_id: u64, pub(crate) cmd_tx: mpsc::UnboundedSender, /// `Some` only between `js_net_socket_alloc` and the first /// `js_net_socket_method_connect`. Held here so the deferred-connect @@ -340,6 +289,9 @@ impl SocketState { /// command-path test, which only needs `cmd_tx` to reach `run_socket_task`. pub(crate) fn for_test(cmd_tx: mpsc::UnboundedSender) -> Self { SocketState { + tcp_async_id: 0, + connect_async_id: 0, + shutdown_async_id: 0, cmd_tx, pending_rx: None, is_open: true, @@ -359,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 @@ -370,6 +322,10 @@ pub(crate) enum SocketCommand { /// the task starts — after the connect site has set the Node default ON, /// so an explicit opt-out wins. SetNoDelay(bool), + /// The main thread finished dispatching the accepted socket's + /// `connection` callback. Commands queued by that callback precede this + /// marker, so a peer FIN may now auto-close without dropping its response. + ServerConnectionReady, /// Test-only: report the live socket's `TCP_NODELAY` state back over a /// oneshot, so the command-path test can observe `setNoDelay` taking /// effect on the stream the task owns. @@ -394,18 +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), + /// Writable-side shutdown requested by `socket.end()`, distinct from FIN; + /// fires the public `end` event. + 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 @@ -435,6 +389,15 @@ enum PendingNetEvent { extern "C" { fn js_net_callback_ptr(value: f64) -> i64; fn js_get_string_pointer_unified(value: f64) -> i64; + fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64; + fn js_async_hooks_provider_init_with_trigger( + type_ptr: *const u8, + type_len: usize, + trigger_async_id: u64, + ) -> u64; + 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 perry_cluster_worker_listening( addr_ptr: *const u8, addr_len: u32, @@ -461,34 +424,6 @@ fn mark_closed(id: i64) { server_state::mark_socket_closed(id); } -// ─── Spawning helper ───────────────────────────────────────────────────────── -// -// perry-ffi v0.5.x's only async-runtime entry point is `spawn_blocking`, -// which boxes a `FnOnce()` to run on tokio's blocking pool. We bridge to -// async-Rust by calling `tokio::runtime::Handle::current().block_on(...)` -// inside the closure — same pattern axios / better-sqlite3 / iroh use. -// One thread per socket for its lifetime; the perry-stdlib version uses -// the same shared tokio runtime via `crate::common::async_bridge::spawn`, -// which is a regular `tokio::spawn` (cooperative). Neither approach is -// "wrong" — the cooperative version is denser, the blocking-pool version -// is simpler. Wrapper-side simplicity wins for a v0 port. -fn spawn_socket_runner(fut_factory: F) -where - F: FnOnce() -> Pin + Send>> + Send + 'static, -{ - // Run each socket future cooperatively on Perry's shared multi-thread - // runtime via `spawn_async`, instead of tying up one blocking-pool thread - // plus a throwaway current-thread runtime for the socket's whole lifetime. - // The shared runtime carries the I/O reactor, so `TcpStream` / TLS work - // without relying on the FFI callback's ambient `Handle` (the brittleness - // under release/LTO that the per-socket runtime worked around). The socket - // is registered in `sockets()` synchronously before this spawn, so - // `js_ext_net_has_active_handles` keeps the event loop alive for its life. - perry_ffi::spawn_async(async move { - fut_factory().await; - }); -} - // ─── FFI: net.createConnection / net.connect ───────────────────────────────── /// `net.createConnection(...)` / `net.connect(...)` — returns a handle @@ -603,9 +538,13 @@ pub unsafe extern "C" fn js_net_socket_alloc() -> i64 { dispatch::ensure_runtime_dispatch_registered(); let id = next_id_or_throw(); let (tx, rx) = mpsc::unbounded_channel::(); + let tcp_async_id = init_provider(b"TCPWRAP"); statics::sockets().lock().unwrap().insert( id, SocketState { + tcp_async_id, + connect_async_id: 0, + shutdown_async_id: 0, cmd_tx: tx, pending_rx: Some(rx), is_open: false, @@ -652,6 +591,7 @@ pub unsafe extern "C" fn js_net_create_server( statics::servers().lock().unwrap().insert( id, ServerState { + async_id: 0, shutdown_tx: None, bound_port: 0, bound_host: String::new(), @@ -677,6 +617,17 @@ pub unsafe extern "C" fn js_net_create_server( id } +/// Collision-proof server factory for generated code. Pulling this distinct +/// symbol from perry-ext-net also ensures the server/socket symbols in this +/// archive win over bundled-stdlib twins with separate handle registries. +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_create_server( + options_i64: i64, + connection_listener_i64: i64, +) -> i64 { + js_net_create_server(options_i64, connection_listener_i64) +} + // ─── FFI: net.Server.listen / .close / .address / .on ──────────────────────── /// `server.listen(port | path, callback?)` — bind TCP, a Windows named pipe, @@ -700,10 +651,9 @@ pub unsafe extern "C" fn js_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() { @@ -719,10 +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::<()>(); @@ -737,6 +692,7 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, Some(s) => s, None => return, }; + s.async_id = server_async_id; s.shutdown_tx = Some(shutdown_tx); s.bound_port = port_u16; s.bound_host = host.clone(); @@ -942,28 +898,6 @@ pub unsafe extern "C" fn js_net_server_address(handle: i64) -> *mut StringHeader alloc_string(&json).as_raw() } -/// `server.on(event, cb)` — register a server-level listener for -/// `'connection'`, `'listening'`, `'close'`, or `'error'`. Reuses -/// the shared listener map keyed on the server id (server ids and -/// socket ids are drawn from the same monotonic counter so they -/// never collide). -/// -/// # Safety -/// -/// `event_ptr` must be null or a Perry-runtime `StringHeader`. `cb` -/// is a raw `*const ClosureHeader` cast to `i64`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) { - ensure_gc_scanner_registered(); - let event = match string_from_header_i64(event_ptr) { - Some(e) => e, - None => return, - }; - let mut listeners = statics::listeners().lock().unwrap(); - let entry = listeners.entry(handle).or_default(); - entry.entry(event).or_default().push(cb); -} - // ─── FFI: socket.connect(port, host) (instance method on existing handle) ───── /// `socket.connect(port, host)` / `socket.connect(path)` — initiates a TCP or @@ -1034,10 +968,19 @@ pub unsafe extern "C" fn js_net_socket_method_connect( let port = port as u16; ipc::register_connect_cb(handle, callback); - let rx = { + let (rx, tcp_async_id) = { let mut guard = statics::sockets().lock().unwrap(); - match guard.get_mut(&handle).and_then(|s| s.pending_rx.take()) { - Some(rx) => rx, + match guard.get_mut(&handle) { + Some(socket) => match socket.pending_rx.take() { + Some(rx) => (rx, socket.tcp_async_id), + None => { + push_event(PendingNetEvent::Error( + handle, + "socket already connected (or unknown handle)".to_string(), + )); + return; + } + }, None => { push_event(PendingNetEvent::Error( handle, @@ -1047,6 +990,10 @@ pub unsafe extern "C" fn js_net_socket_method_connect( } } }; + let connect_async_id = init_provider_with_trigger(b"TCPCONNECTWRAP", tcp_async_id); + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { + socket.connect_async_id = connect_async_id; + } let local_server = server_state::begin_local_connect(&host, port); spawn_socket_runner(move || { @@ -1116,10 +1063,15 @@ where .is_none() .then(|| server_state::begin_local_connect(&host, port)) .flatten(); + let tcp_async_id = unsafe { init_provider(b"TCPWRAP") }; + let connect_async_id = unsafe { init_provider_with_trigger(b"TCPCONNECTWRAP", tcp_async_id) }; statics::sockets().lock().unwrap().insert( id, SocketState { + tcp_async_id, + connect_async_id, + shutdown_async_id: 0, cmd_tx: tx, pending_rx: None, is_open: false, @@ -1205,6 +1157,13 @@ pub(crate) async fn run_socket_task( rx: &mut mpsc::UnboundedReceiver, ) { let mut transport: Option = Some(initial_transport); + let mut writable_ended = false; + let accepted_socket = statics::sockets() + .lock() + .ok() + .and_then(|sockets| sockets.get(&id).map(|socket| socket.server_id.is_some())) + .unwrap_or(false); + let mut server_connection_ready = !accepted_socket; loop { let t = match transport.as_mut() { @@ -1246,18 +1205,91 @@ pub(crate) async fn run_socket_task( // breaking, so a peer FIN doesn't leak its pooled // capacity (the success path checks in below). buffer_pool::checkin(buf); - // #2154 raw mode: signal EOF on the buffer, suppress - // JS events. Else (#1852) fire 'end' then 'close' per - // Node's default `allowHalfOpen: false` teardown order. - // Complete the writable half before dropping the - // transport. For TLS this sends close_notify; without - // it the peer observes an unclean EOF and emits only - // 'close', skipping its 'end' event. - let _ = t.shutdown().await; - if !raw_bridge::mark_terminal(id, None) { - push_event(PendingNetEvent::End(id)); - push_event(PendingNetEvent::Close(id)); + // #2154 raw mode owns its own terminal state. Accepted + // sockets may receive a request plus FIN before their + // delayed `connection` callback runs. Wait for the + // callback-complete marker so its queued writes/end + // commands are honored. Outgoing sockets start ready + // and therefore retain Node's default auto-close after + // readable EOF (#6764). + if raw_bridge::mark_terminal(id, None) { + // Complete the writable half before dropping a TLS + // transport so the peer observes close_notify rather + // than an unclean EOF (#8688). + let _ = t.shutdown().await; + mark_closed(id); + break; + } + push_event(PendingNetEvent::End(id)); + + while accepted_socket && !writable_ended { + let command = if server_connection_ready { + match tokio::time::timeout( + std::time::Duration::from_millis(25), + rx.recv(), + ) + .await + { + Ok(command) => command, + Err(_) => break, + } + } else { + rx.recv().await + }; + match command { + 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()), + )); + } + push_event(PendingNetEvent::Error(id, msg)); + break; + } + if completion != 0 { + push_event(PendingNetEvent::WriteComplete( + id, + completion, + None, + )); + } + } + Some(SocketCommand::End(completion)) => { + let error = t.shutdown().await.err().map(|e| e.to_string()); + writable_ended = true; + push_event(PendingNetEvent::ShutdownComplete( + id, + completion, + error, + )); + } + Some(SocketCommand::SetNoDelay(enable)) => { + let _ = t.set_nodelay(enable); + } + Some(SocketCommand::ServerConnectionReady) => { + server_connection_ready = true; + } + #[cfg(test)] + Some(SocketCommand::QueryNoDelay(reply)) => { + let _ = reply.send(t.nodelay().unwrap_or(false)); + } + Some(SocketCommand::UpgradeTls { reply, .. }) => { + let _ = reply.send(Err( + "cannot upgrade a half-closed socket".to_string(), + )); + } + Some(SocketCommand::Destroy) | None => break, + } } + if !writable_ended { + let _ = t.shutdown().await; + push_event(PendingNetEvent::ShutdownComplete(id, 0, None)); + } + push_event(PendingNetEvent::Close(id)); mark_closed(id); break; } @@ -1284,7 +1316,16 @@ pub(crate) async fn run_socket_task( buffer_pool::checkin(buf); let msg = format!("{}", e); if !raw_bridge::mark_terminal(id, Some(msg.clone())) { - push_event(PendingNetEvent::Error(id, msg)); + // rustls reports a peer that closes TCP without a + // close_notify alert as UnexpectedEof. Node's TLS + // socket treats that terminal read as the readable + // side ending, so preserve the normal end→close + // event order instead of silently losing `end`. + if msg.contains("close_notify") || msg.contains("unexpected end of file") { + push_event(PendingNetEvent::End(id)); + } else { + push_event(PendingNetEvent::Error(id, msg)); + } push_event(PendingNetEvent::Close(id)); } mark_closed(id); @@ -1299,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)); @@ -1309,15 +1357,23 @@ 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, completion, error)); } Some(SocketCommand::SetNoDelay(enable)) => { // Best-effort, matching Node: a failed setsockopt (e.g. // the peer already closed) does not error the socket. let _ = t.set_nodelay(enable); } + Some(SocketCommand::ServerConnectionReady) => { + server_connection_ready = true; + } #[cfg(test)] Some(SocketCommand::QueryNoDelay(reply)) => { let _ = reply.send(t.nodelay().unwrap_or(false)); @@ -1580,6 +1636,40 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { let count = events.len() as i32; for ev in events.drain(..) { + prepare_event_provider(&ev); + let provider_id = event_provider_id(&ev); + let destroy_ids: Vec = match &ev { + PendingNetEvent::Connect(id, _) => statics::sockets() + .lock() + .ok() + .and_then(|sockets| sockets.get(id).map(|socket| vec![socket.connect_async_id])) + .unwrap_or_default(), + PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets() + .lock() + .ok() + .and_then(|sockets| sockets.get(id).map(|socket| vec![socket.shutdown_async_id])) + .unwrap_or_default(), + PendingNetEvent::Close(id) => statics::sockets() + .lock() + .ok() + .and_then(|sockets| { + sockets.get(id).map(|socket| { + vec![ + socket.connect_async_id, + socket.shutdown_async_id, + socket.tcp_async_id, + ] + }) + }) + .unwrap_or_default(), + PendingNetEvent::ServerClose(id) => statics::servers() + .lock() + .ok() + .and_then(|servers| servers.get(id).map(|server| vec![server.async_id])) + .unwrap_or_default(), + _ => Vec::new(), + }; + let provider_scope = ProviderScope::enter(provider_id); match ev { PendingNetEvent::Connect(id, local_server) => { server_state::finish_local_connect(local_server); @@ -1701,7 +1791,12 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { drop(frame); lifecycle::drain_once_listeners(id, "end"); } + 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); } @@ -1739,6 +1834,7 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { // once-set may still be holding stale entries. lifecycle::drain_once_listeners(server_id, "connection"); server_state::release_pending_server_data(socket_id); + server_state::release_connection_callback(socket_id); continue; } // Sockets returned by the codegen's `net.connect` @@ -1767,6 +1863,7 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { drop(frame); lifecycle::drain_once_listeners(server_id, "connection"); server_state::release_pending_server_data(socket_id); + server_state::release_connection_callback(socket_id); } PendingNetEvent::ServerListening(server_id) => { // Take + drain the 'listening' listeners so the @@ -1869,6 +1966,12 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { lifecycle::drain_once_listeners(server_id, "drop"); } } + drop(provider_scope); + for async_id in destroy_ids { + if async_id != 0 { + js_async_hooks_provider_destroy(async_id); + } + } } // Restore the (capacity-retaining) buffer to the thread-local so the @@ -1884,106 +1987,14 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { count } -fn listeners_for(id: i64, event: &str) -> Vec { - statics::listeners() - .lock() - .unwrap() - .get(&id) - .and_then(|m| m.get(event).cloned()) - .unwrap_or_default() -} - -// `drain_once_listeners` lives in `lifecycle::drain_once_listeners` so -// the file-size gate keeps a single owner for the EventEmitter surface. - -/// Returns 1 if queued events or live net handles keep the loop alive. -#[no_mangle] -pub extern "C" fn js_net_has_pending() -> i32 { - server_state::has_active_handles() as i32 -} - -/// True iff `handle` is a currently-registered net socket id. Mirrors the -/// perry-stdlib export so codegen's `HANDLE_METHOD_DISPATCH` keeps working. -pub fn is_net_socket_handle(handle: i64) -> bool { - statics::sockets().lock().unwrap().contains_key(&handle) -} - -/// True iff `handle` is a currently-registered net server id. -pub fn is_net_server_handle(handle: i64) -> bool { - statics::servers().lock().unwrap().contains_key(&handle) -} - -/// `server.listening` — boolean state exposed through handle property dispatch. -#[no_mangle] -pub extern "C" fn js_net_server_listening(handle: i64) -> i32 { - match statics::servers().lock() { - Ok(servers) => servers - .get(&handle) - .map(|server| if server.listening { 1 } else { 0 }) - .unwrap_or(0), - Err(_) => 0, - } -} - -/// `extern "C"` form of `is_net_socket_handle` — used by -/// perry-stdlib's `common::dispatch::dispatch_handle_method` -/// (HANDLE_METHOD_DISPATCH) when bundled-net is stripped and -/// the well-known flip routes 'net' to perry-ext-net. Returns -/// 1 for a registered socket handle, 0 otherwise. -/// -/// Closes the issue #91 regression: Map.get'd / struct-field / -/// wrapper-function receivers where codegen lost the static type -/// fall through to `js_native_call_method` → -/// `dispatch_handle_method` → this query → `dispatch_net_socket`. -/// Without the extern, the dispatch tower's `is_net_socket_handle` -/// reference resolved to perry-stdlib's no-op stub (compiled-out -/// when bundled-net is off) and Map-retrieved sockets silently -/// dispatched to undefined. -/// Distinct-symbol aliases for the socket EVENT-LISTENER surface (#5021's -/// twin-symbol disease). perry-stdlib exports same-named `js_net_socket_on` / -/// `_once` / `_remove_listener` twins, so in a build that links BOTH archives -/// the shared names bind to the bundled twin's EMPTY socket registry and the -/// listener registration is silently dropped: the socket connects, the reader -/// task delivers bytes, and the pump finds ZERO 'data' listeners — mysql2's -/// handshake then hangs to ETIMEDOUT. `write`/`end`/`destroy` were split out -/// for exactly this reason (#5010/#5021); the listener calls were not. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) { - js_net_socket_on(handle, event_ptr, cb) -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { - js_net_socket_once(handle, event_ptr, cb) -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_remove_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - js_net_socket_remove_listener(handle, event_ptr, cb) -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_remove_all_listeners( - handle: i64, - event_ptr: i64, -) -> i64 { - js_net_socket_remove_all_listeners(handle, event_ptr) -} - -/// `extern "C"` form of `is_net_server_handle` for method-value/property -/// dispatch on `net.Server` handles. -#[no_mangle] -pub extern "C" fn js_ext_net_is_server_handle(handle: i64) -> i32 { - if is_net_server_handle(handle) { - 1 - } else { - 0 - } -} +mod handle_exports; +use handle_exports::listeners_for; +pub use handle_exports::{ + is_net_server_handle, is_net_socket_handle, js_ext_net_is_server_handle, js_ext_net_socket_on, + js_ext_net_socket_once, js_ext_net_socket_remove_all_listeners, + js_ext_net_socket_remove_listener, js_net_has_pending, js_net_server_listening, + js_net_server_on, +}; #[cfg(test)] mod tests; diff --git a/crates/perry-ext-net/src/lifecycle.rs b/crates/perry-ext-net/src/lifecycle.rs index da2754ec8e..2dee4476c3 100644 --- a/crates/perry-ext-net/src/lifecycle.rs +++ b/crates/perry-ext-net/src/lifecycle.rs @@ -22,6 +22,8 @@ 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; @@ -54,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(); @@ -295,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); } } @@ -315,6 +363,55 @@ pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { js_ext_net_socket_write(handle, chunk_bits); } +unsafe fn socket_completion(values: [f64; 3]) -> i64 { + extern "C" { + fn js_value_is_closure(value_bits: i64) -> i32; + } + values + .into_iter() + .find(|value| js_value_is_closure(value.to_bits() as i64) != 0) + .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])`. +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_write3( + handle: i64, + chunk: f64, + encoding_or_callback: f64, + callback: f64, +) { + 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 /// write side (#1852). `undefined`/`null` (the no-arg form, padded with /// `TAG_UNDEFINED`) yields `None` and we just send FIN. @@ -330,15 +427,29 @@ pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { /// 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) + .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(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)); } } @@ -353,6 +464,55 @@ pub unsafe extern "C" fn js_net_socket_end(handle: i64, chunk_bits: i64) { js_ext_net_socket_end(handle, chunk_bits); } +/// Full Node overload for `socket.end([data][, encoding][, callback])`. +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_end3( + handle: i64, + chunk_or_callback: f64, + encoding_or_callback: f64, + callback: f64, +) { + 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 /// `socket.destroyed` / `readyState` reflect it) and sends the teardown /// command. @@ -443,6 +603,7 @@ fn register_listener_with_flag(handle: i64, event: String, cb: i64, once: bool) if cb == 0 { return; } + let releases_pending_data = event == "data"; { let mut listeners = statics::listeners().lock().unwrap(); listeners @@ -461,6 +622,9 @@ fn register_listener_with_flag(handle: i64, event: String, cb: i64, once: bool) .or_default() .insert(cb); } + if releases_pending_data { + crate::server_state::release_pending_server_data(handle); + } } /// Issue #2131 — drop any callback pointer flagged as a `once` listener diff --git a/crates/perry-ext-net/src/provider_lifecycle.rs b/crates/perry-ext-net/src/provider_lifecycle.rs new file mode 100644 index 0000000000..7f276cd07c --- /dev/null +++ b/crates/perry-ext-net/src/provider_lifecycle.rs @@ -0,0 +1,108 @@ +use super::*; + +pub(super) unsafe fn init_provider(name: &'static [u8]) -> u64 { + js_async_hooks_provider_init(name.as_ptr(), name.len()) +} + +pub(super) unsafe fn init_provider_with_trigger(name: &'static [u8], trigger_async_id: u64) -> u64 { + js_async_hooks_provider_init_with_trigger(name.as_ptr(), name.len(), trigger_async_id) +} + +pub(super) struct ProviderScope(u64); + +impl ProviderScope { + pub(super) unsafe fn enter(async_id: u64) -> Self { + if async_id != 0 { + js_async_hooks_provider_enter(async_id); + } + Self(async_id) + } +} + +impl Drop for ProviderScope { + fn drop(&mut self) { + if self.0 != 0 { + unsafe { js_async_hooks_provider_leave(self.0) }; + } + } +} + +pub(super) unsafe fn prepare_event_provider(ev: &PendingNetEvent) { + match ev { + PendingNetEvent::ServerConnection(server_id, socket_id, _) => { + let server_async_id = statics::servers() + .lock() + .ok() + .and_then(|servers| servers.get(server_id).map(|server| server.async_id)) + .unwrap_or(0); + let needs_init = statics::sockets() + .lock() + .ok() + .and_then(|sockets| { + sockets + .get(socket_id) + .map(|socket| socket.tcp_async_id == 0) + }) + .unwrap_or(false); + if needs_init { + let async_id = init_provider_with_trigger(b"TCPWRAP", server_async_id); + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(socket_id) { + socket.tcp_async_id = async_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) + }) + }); + if let Some(trigger) = trigger { + let async_id = init_provider_with_trigger(b"SHUTDOWNWRAP", trigger); + if let Some(socket) = statics::sockets().lock().unwrap().get_mut(id) { + socket.shutdown_async_id = async_id; + } + } + } + _ => {} + } +} + +pub(super) fn event_provider_id(ev: &PendingNetEvent) -> u64 { + match ev { + PendingNetEvent::Connect(id, _) => statics::sockets() + .lock() + .ok() + .and_then(|sockets| sockets.get(id).map(|socket| socket.connect_async_id)) + .unwrap_or(0), + PendingNetEvent::SecureConnect(id) + | PendingNetEvent::Data(id, _) + | PendingNetEvent::End(id) + | PendingNetEvent::WriteComplete(id, _, _) + | PendingNetEvent::Error(id, _) + | PendingNetEvent::AbortError(id) + | PendingNetEvent::Close(id) => statics::sockets() + .lock() + .ok() + .and_then(|sockets| sockets.get(id).map(|socket| socket.tcp_async_id)) + .unwrap_or(0), + PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets() + .lock() + .ok() + .and_then(|sockets| sockets.get(id).map(|socket| socket.shutdown_async_id)) + .unwrap_or(0), + PendingNetEvent::ServerConnection(_, socket_id, _) => statics::sockets() + .lock() + .ok() + .and_then(|sockets| sockets.get(socket_id).map(|socket| socket.tcp_async_id)) + .unwrap_or(0), + PendingNetEvent::ServerListening(id) + | PendingNetEvent::ServerClose(id) + | PendingNetEvent::ServerError(id, _) + | PendingNetEvent::ServerDrop(id, _) => statics::servers() + .lock() + .ok() + .and_then(|servers| servers.get(id).map(|server| server.async_id)) + .unwrap_or(0), + } +} 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-net/src/server_state.rs b/crates/perry-ext-net/src/server_state.rs index 9feadc9dc3..e16cebf739 100644 --- a/crates/perry-ext-net/src/server_state.rs +++ b/crates/perry-ext-net/src/server_state.rs @@ -111,14 +111,10 @@ pub(crate) fn defer_server_connection(server_id: i64, socket_id: i64) -> bool { pub(crate) fn buffer_pending_server_data(socket_id: i64, bytes: Bytes) { let mut state = connection_order_state().lock().unwrap(); - let pending_connection = statics::sockets() - .lock() - .unwrap() - .get(&socket_id) - .is_some_and(|socket| socket.server_id.is_some() && !socket.server_connection_active); - if !pending_connection { - return; - } + // Reads can win the race with JavaScript listener registration on both + // accepted and outbound sockets. Keep every otherwise-undeliverable + // chunk here; registration releases it immediately, while close() drops + // abandoned data through `discard_pending_server_data`. state .pending_socket_data .entry(socket_id) @@ -337,6 +333,17 @@ pub(crate) fn activate_connection(server_id: i64, socket_id: i64) { } } +/// Tell the socket task that JavaScript's accepted-socket callback has +/// returned. Any writes/end queued by that callback are already ahead of this +/// marker in the channel, so peer-EOF handling can safely auto-close after it. +pub(crate) fn release_connection_callback(socket_id: i64) { + if let Some(socket) = statics::sockets().lock().unwrap().get(&socket_id) { + let _ = socket + .cmd_tx + .send(crate::SocketCommand::ServerConnectionReady); + } +} + pub(crate) fn mark_socket_closed(socket_id: i64) { let mut sockets = statics::sockets().lock().unwrap(); let Some(socket) = sockets.get_mut(&socket_id) else { diff --git a/crates/perry-ext-net/src/task_spawn.rs b/crates/perry-ext-net/src/task_spawn.rs new file mode 100644 index 0000000000..f2a67b1c61 --- /dev/null +++ b/crates/perry-ext-net/src/task_spawn.rs @@ -0,0 +1,18 @@ +//! Cooperative socket-task spawning on Perry's shared Tokio runtime. + +use std::pin::Pin; + +// perry-ffi v0.5.x's only async-runtime entry point was originally +// `spawn_blocking`, which boxed a `FnOnce()` on Tokio's blocking pool. Socket +// futures now run cooperatively through `spawn_async`, matching perry-stdlib's +// shared-runtime path without tying up one blocking-pool thread per socket. +pub(super) fn spawn_socket_runner(fut_factory: F) +where + F: FnOnce() -> Pin + Send>> + Send + 'static, +{ + // The socket is registered synchronously before this spawn, so + // `js_ext_net_has_active_handles` keeps the event loop alive for its life. + perry_ffi::spawn_async(async move { + fut_factory().await; + }); +} diff --git a/crates/perry-ext-net/src/tests.rs b/crates/perry-ext-net/src/tests.rs index c276524a26..6363aa55cf 100644 --- a/crates/perry-ext-net/src/tests.rs +++ b/crates/perry-ext-net/src/tests.rs @@ -86,7 +86,10 @@ fn assert_rewritten(before: i64, after: i64) { #[test] fn gc_mutable_scanner_rewrites_listener_roots() { let _guard = GcTestGuard::new(); - perry_ffi::gc_register_mutable_root_scanner_named("perry-ext-net", scan_net_roots); + perry_ffi::gc_register_mutable_root_scanner_named( + "perry-ext-net", + crate::gc_roots::scan_net_roots, + ); // Keep an ordinary shadow-stack root as the control. Its rewrite proves // the collection copied live objects independently of scan_net_roots, so diff --git a/crates/perry-ext-net/src/tls.rs b/crates/perry-ext-net/src/tls.rs index 4270af5c0b..bbc39514ef 100644 --- a/crates/perry-ext-net/src/tls.rs +++ b/crates/perry-ext-net/src/tls.rs @@ -926,3 +926,12 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f } handle } + +/// Collision-proof entry point for AOT calls. Both bundled stdlib net and +/// perry-ext-net export `js_tls_connect`; auto-optimized binaries may bind the +/// shared name to the bundled registry while their event pump owns ext-net +/// sockets. Keep generated TLS clients in the same backend as the pump. +#[no_mangle] +pub unsafe extern "C" fn js_ext_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { + js_tls_connect(arg1, arg2, arg3, arg4) +} diff --git a/crates/perry-ext-zlib/src/stream.rs b/crates/perry-ext-zlib/src/stream.rs index 717208fc9f..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}; @@ -66,6 +67,10 @@ extern "C" { // Async one-shot zlib helpers require a callable callback and throw // 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( object: f64, name_handle: i64, @@ -467,6 +472,7 @@ fn make_codec_state_with_level(codec: Codec, level: Compression) -> Option), + Finish(i64), End(i64), Error(i64, String), /// `.flush(cb)` completion callback — invoked (0 args) after its flushed /// 'data' is delivered. Callback(i64), /// `zlib.gzip(data, cb)` style one-shot completion callback. - OneShotCallback(i64, Result, String>), + OneShotCallback(i64, Result, String>, u64), } struct Statics { @@ -560,7 +567,7 @@ fn scan_zlib_roots(visitor: &mut GcRootVisitor<'_>) { // hazard as listeners. for ev in s.pending.iter_mut() { match ev { - ZlibEvent::Callback(cb) | ZlibEvent::OneShotCallback(cb, _) => { + ZlibEvent::Callback(cb) | ZlibEvent::OneShotCallback(cb, _, _) => { visitor.visit_i64_slot(cb); } _ => {} @@ -577,12 +584,14 @@ fn create_stream(codec: Codec, level: Compression) -> i64 { // unnecessary and unsafe in stripped well-known-wrapper builds. ensure_aux_pump_registered(); ensure_gc_scanner_registered(); + let async_id = unsafe { js_async_hooks_provider_init(b"ZLIB".as_ptr(), b"ZLIB".len()) }; let mut s = statics().lock().unwrap(); let id = s.next_id; s.next_id += 1; s.streams.insert( id, ZlibStreamState { + async_id, codec, level, codec_state: make_codec_state_with_level(codec, level), @@ -699,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) { @@ -708,11 +719,16 @@ 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() .pending - .push_back(ZlibEvent::OneShotCallback(callback, result)); + .push_back(ZlibEvent::OneShotCallback(callback, result, async_id)); notify_main_thread(); } @@ -872,6 +888,9 @@ fn finish_stream(handle: i64) { let mut g = statics().lock().unwrap(); match result { Ok(out) => { + // Writable completion precedes the final readable bytes/end + // of a Transform stream in Node. + g.pending.push_back(ZlibEvent::Finish(handle)); if !out.is_empty() { g.pending.push_back(ZlibEvent::Data(handle, out)); } @@ -963,11 +982,22 @@ fn flush_buffered(handle: i64) { /// `OneShotCallback` carry only a closure, so they are not tied to a stream. fn event_stream_handle(ev: &ZlibEvent) -> Option { match ev { - ZlibEvent::Data(id, _) | ZlibEvent::End(id) | ZlibEvent::Error(id, _) => Some(*id), - ZlibEvent::Callback(_) | ZlibEvent::OneShotCallback(_, _) => None, + ZlibEvent::Data(id, _) + | ZlibEvent::Finish(id) + | ZlibEvent::End(id) + | ZlibEvent::Error(id, _) => Some(*id), + ZlibEvent::Callback(_) | ZlibEvent::OneShotCallback(_, _, _) => None, } } +fn stream_async_id(handle: i64) -> u64 { + statics() + .lock() + .ok() + .and_then(|g| g.streams.get(&handle).map(|stream| stream.async_id)) + .unwrap_or(0) +} + /// Splice late-flushed buffered `Data` (then the deferred `End`) ahead of any /// newer queued events for `handle`, so the FIFO `process_pending` drain still /// delivers a late consumer its chunks in write order. Inserts at the first @@ -1314,6 +1344,11 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { } }; count += 1; + let event_async_id = event_stream_handle(&ev).map(stream_async_id).unwrap_or(0); + let mut destroy_after_dispatch = 0; + if event_async_id != 0 { + js_async_hooks_provider_enter(event_async_id); + } match ev { ZlibEvent::Data(id, bytes) => { publish_bytes_written(id); @@ -1341,6 +1376,16 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { } } } + ZlibEvent::Finish(id) => { + let scope = TransientRootScope::enter(); + let callbacks = scope.root_addrs(&listeners_for(id, "finish")); + for cb in callbacks { + let cb = cb.get(); + if cb != 0 { + let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); + } + } + } ZlibEvent::End(id) => { publish_bytes_written(id); // Defer `'end'` (keep the stream + its buffer alive) when no @@ -1366,6 +1411,9 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { // listener or pipe) can't pin its buffered output for the // process lifetime; drop the oldest excess. evict_excess_buffered_ended(&mut g); + if event_async_id != 0 { + js_async_hooks_provider_leave(event_async_id); + } continue; } // Stream already gone — release the lock and fall through to @@ -1377,11 +1425,6 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); } } - for cb in listeners_for(id, "finish") { - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } - } for dest in pipes_for(id) { forward_end(dest); } @@ -1391,14 +1434,27 @@ 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 { let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); } } - ZlibEvent::OneShotCallback(cb, result) => { - call_one_shot_callback(cb, result); + 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(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); @@ -1408,8 +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 } @@ -1625,6 +1688,7 @@ mod stream_tests { fn no_consumer_state() -> ZlibStreamState { ZlibStreamState { + async_id: 0, codec: Codec::Gzip, level: Compression::default(), codec_state: None, @@ -1652,6 +1716,7 @@ mod stream_tests { pipes: Vec::new(), output_buffer: b"buffered".to_vec(), end_buffered: true, + async_id: 0, } } diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index be47e4de4e..34423b2301 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1080,6 +1080,23 @@ pub(crate) fn lower_stmt( cn.to_string(), )); } + // Named-import factories lower directly to a + // receiver-less NativeMethodCall. The AST + // member-call registration in native_new.rs + // therefore never sees + // `import { createInterface } ...; const rl = + // createInterface(...)`, especially inside a + // Promise executor. Preserve the Interface + // identity here so adjacent `rl.on(...)` calls + // reach the readline FFI instead of generic + // small-handle dispatch (#6764). + if mod_name == "readline" && method == "createInterface" { + ctx.register_native_instance( + name.clone(), + "readline".to_string(), + "Interface".to_string(), + ); + } // Issue #769 — node:http / node:https CLIENT factories. // `const req = http.request(url, cb)` and `http.get` / `https.*` // variants return a ClientRequest handle; register under diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index db318cd4c7..74ee48cbd1 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -214,6 +214,7 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Some(("net", "Server")), ("net", "BlockList") => Some(("net", "BlockList")), ("net", "SocketAddress") => Some(("net", "SocketAddress")), + ("readline", "createInterface") => Some(("readline", "Interface")), _ => None, }; if let Some((m, c)) = native_class { diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index d6b621d803..0b20ff6ba7 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -254,9 +254,6 @@ fn dense_layout_for_value(value: f64) -> Option<(*const ObjectHeader, DenseSubcl return None; } let obj = js.as_pointer::(); - if obj.is_null() || !crate::object::is_valid_obj_ptr(obj.cast::()) { - return None; - } let header = unsafe { crate::value::addr_class::try_read_gc_header(obj as usize)? }; if header.obj_type != crate::gc::GC_TYPE_OBJECT || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 diff --git a/crates/perry-runtime/src/async_context.rs b/crates/perry-runtime/src/async_context.rs index 770d1bafe3..fa80f61c1c 100644 --- a/crates/perry-runtime/src/async_context.rs +++ b/crates/perry-runtime/src/async_context.rs @@ -6,6 +6,7 @@ //! while the callback runs. use std::cell::RefCell; +use std::collections::HashMap; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; @@ -23,11 +24,23 @@ impl AsyncContextSnapshot { #[derive(Clone)] struct AsyncContextEntry { handle: i64, + generation: u64, stores: Vec, } thread_local! { static ACTIVE_CONTEXT: RefCell = RefCell::new(AsyncContextSnapshot::default()); + static HANDLE_GENERATIONS: RefCell> = RefCell::new(HashMap::new()); +} + +fn handle_generation(handle: i64) -> u64 { + HANDLE_GENERATIONS.with(|generations| generations.borrow().get(&handle).copied().unwrap_or(0)) +} + +fn discard_disabled_entries(snapshot: &mut AsyncContextSnapshot) { + snapshot + .entries + .retain(|entry| entry.generation == handle_generation(entry.handle)); } pub fn capture_context() -> AsyncContextSnapshot { @@ -38,12 +51,15 @@ pub fn enter_context(snapshot: &AsyncContextSnapshot) -> AsyncContextSnapshot { ACTIVE_CONTEXT.with(|ctx| { let mut ctx = ctx.borrow_mut(); let previous = ctx.clone(); - *ctx = snapshot.clone(); + let mut next = snapshot.clone(); + discard_disabled_entries(&mut next); + *ctx = next; previous }) } -pub fn restore_context(snapshot: AsyncContextSnapshot) { +pub fn restore_context(mut snapshot: AsyncContextSnapshot) { + discard_disabled_entries(&mut snapshot); ACTIVE_CONTEXT.with(|ctx| { *ctx.borrow_mut() = snapshot; }); @@ -104,13 +120,21 @@ pub extern "C" fn js_async_context_als_clear(handle: i64) { } pub fn push_store(handle: i64, store: f64) { + let generation = handle_generation(handle); ACTIVE_CONTEXT.with(|ctx| { let mut ctx = ctx.borrow_mut(); - if let Some(entry) = ctx.entries.iter_mut().find(|entry| entry.handle == handle) { + ctx.entries + .retain(|entry| entry.handle != handle || entry.generation == generation); + if let Some(entry) = ctx + .entries + .iter_mut() + .find(|entry| entry.handle == handle && entry.generation == generation) + { entry.stores.push(store); } else { ctx.entries.push(AsyncContextEntry { handle, + generation, stores: vec![store], }); } @@ -118,9 +142,14 @@ pub fn push_store(handle: i64, store: f64) { } pub fn pop_store(handle: i64) { + let generation = handle_generation(handle); ACTIVE_CONTEXT.with(|ctx| { let mut ctx = ctx.borrow_mut(); - if let Some(index) = ctx.entries.iter().position(|entry| entry.handle == handle) { + if let Some(index) = ctx + .entries + .iter() + .position(|entry| entry.handle == handle && entry.generation == generation) + { ctx.entries[index].stores.pop(); if ctx.entries[index].stores.is_empty() { ctx.entries.remove(index); @@ -130,11 +159,12 @@ pub fn pop_store(handle: i64) { } pub fn get_store(handle: i64) -> Option { + let generation = handle_generation(handle); ACTIVE_CONTEXT.with(|ctx| { ctx.borrow() .entries .iter() - .find(|entry| entry.handle == handle) + .find(|entry| entry.handle == handle && entry.generation == generation) .and_then(|entry| entry.stores.last().copied()) }) } @@ -146,9 +176,16 @@ pub fn get_store(handle: i64) -> Option { /// (which saves/restores exactly one slot for its own handle) still restores /// the pre-`run` value on exit (#788, differential case 21). pub fn set_store(handle: i64, store: f64) { + let generation = handle_generation(handle); ACTIVE_CONTEXT.with(|ctx| { let mut ctx = ctx.borrow_mut(); - if let Some(entry) = ctx.entries.iter_mut().find(|entry| entry.handle == handle) { + ctx.entries + .retain(|entry| entry.handle != handle || entry.generation == generation); + if let Some(entry) = ctx + .entries + .iter_mut() + .find(|entry| entry.handle == handle && entry.generation == generation) + { if let Some(slot) = entry.stores.last_mut() { *slot = store; } else { @@ -157,6 +194,7 @@ pub fn set_store(handle: i64, store: f64) { } else { ctx.entries.push(AsyncContextEntry { handle, + generation, stores: vec![store], }); } @@ -177,7 +215,7 @@ pub enum ContextGuardAction { /// `run()`: pop the one store slot the scope pushed for its handle. PopStore(i64), /// `exit()`: restore the handle's store stack removed at entry. - RestoreStores(i64, Option>), + RestoreStores(i64, Option<(u64, Vec)>), /// `runInAsyncScope()` / snapshot trampoline: restore the full snapshot. RestoreSnapshot(AsyncContextSnapshot), /// Silently pop one async_hooks execution-id frame (no `after` hook @@ -246,7 +284,7 @@ fn scan_context_guard_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) match &mut guard.action { ContextGuardAction::PopStore(_) | ContextGuardAction::RestoreExecutionIds => {} ContextGuardAction::RestoreStores(_, stores) => { - if let Some(stores) = stores { + if let Some((_, stores)) = stores { for store in stores.iter_mut() { visitor.visit_nanbox_f64_slot(store); } @@ -261,6 +299,36 @@ fn scan_context_guard_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) } pub fn clear_store(handle: i64) { + // `disable()` invalidates descendants captured from a currently-active + // store, but Node leaves already-captured work alone when the storage is + // disabled after its `run()` scope has returned. Generation-bump only in + // the former case so another ALS in the same pending snapshot is not + // disturbed either. + let was_active = ACTIVE_CONTEXT.with(|ctx| { + ctx.borrow() + .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| { + let mut generations = generations.borrow_mut(); + let generation = generations.entry(handle).or_insert(0); + *generation = generation.wrapping_add(1); + }); + } + remove_store(handle); +} + +fn remove_store(handle: i64) { ACTIVE_CONTEXT.with(|ctx| { ctx.borrow_mut() .entries @@ -268,13 +336,17 @@ pub fn clear_store(handle: i64) { }); } -pub fn take_store(handle: i64) -> Option> { +pub fn take_store(handle: i64) -> Option<(u64, Vec)> { + let generation = handle_generation(handle); ACTIVE_CONTEXT.with(|ctx| { let mut ctx = ctx.borrow_mut(); ctx.entries .iter() - .position(|entry| entry.handle == handle) - .map(|index| ctx.entries.remove(index).stores) + .position(|entry| entry.handle == handle && entry.generation == generation) + .map(|index| { + let entry = ctx.entries.remove(index); + (entry.generation, entry.stores) + }) }) } @@ -283,14 +355,16 @@ pub fn take_store(handle: i64) -> Option> { /// `take_store` returns `Some` only for an existing entry, and live entries are /// kept non-empty by `pop_store`. The empty guard below is defensive for manual /// callers and prevents inert context entries from accumulating. -pub fn restore_store(handle: i64, stores: Option>) { - clear_store(handle); - if let Some(stores) = stores { - if !stores.is_empty() { +pub fn restore_store(handle: i64, stores: Option<(u64, Vec)>) { + remove_store(handle); + if let Some((generation, stores)) = stores { + if !stores.is_empty() && generation == handle_generation(handle) { ACTIVE_CONTEXT.with(|ctx| { - ctx.borrow_mut() - .entries - .push(AsyncContextEntry { handle, stores }); + ctx.borrow_mut().entries.push(AsyncContextEntry { + handle, + generation, + stores, + }); }); } } @@ -389,6 +463,7 @@ pub(crate) fn test_snapshot_with_store(store: f64) -> AsyncContextSnapshot { AsyncContextSnapshot { entries: vec![AsyncContextEntry { handle: -1, + generation: handle_generation(-1), stores: vec![store], }], } diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 67077a09c7..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; @@ -49,6 +57,7 @@ per_test_global! { static NEXT_ASYNC_ID: AtomicU64 = AtomicU64::new(2); pub static HOOKS_ACTIVE: AtomicUsize = AtomicUsize::new(0); static PROMISE_HOOKS_ACTIVE: AtomicUsize = AtomicUsize::new(0); + static TOP_LEVEL_RESOURCE: AtomicU64 = AtomicU64::new(0); } #[derive(Clone, Copy)] @@ -155,6 +164,7 @@ per_test_global! { static ASYNC_RESOURCE_HANDLES: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); static ASYNC_RESOURCE_HANDLE_COUNT: AtomicUsize = AtomicUsize::new(0); +const ASYNC_RESOURCE_SUBCLASS_KEY: &[u8] = b"__perryAsyncResourceBacking"; /// Live `AsyncHook` handles, for the same dynamic-receiver reason as /// `ASYNC_RESOURCE_HANDLES`. A helper that returns @@ -168,7 +178,14 @@ thread_local! { static EXECUTION_STACK: RefCell> = const { RefCell::new(Vec::new()) }; static CURRENT_EXECUTION_ID: Cell = const { Cell::new(0) }; static CURRENT_TRIGGER_ID: Cell = const { Cell::new(0) }; - static IN_HOOK_CALLBACK: Cell = const { Cell::new(false) }; + // Node defers hook-list mutations made by a hook callback until the + // outermost hook-delivery cascade has finished. In particular, an init + // callback can synchronously create another resource; that nested init + // must still see the hook set that was active at the start of the outer + // init. Keep the last requested state for each hook while any lifecycle + // callback is on the stack, then commit the batch at depth zero. + static HOOK_CALLBACK_DEPTH: Cell = const { Cell::new(0) }; + static PENDING_HOOK_STATES: RefCell> = RefCell::new(HashMap::new()); } pub struct AsyncHookHandle { @@ -177,6 +194,33 @@ pub struct AsyncHookHandle { pub struct AsyncResourceHandle { ids: AsyncResourceIds, + event_emitter: i64, +} + +pub(crate) fn is_async_resource_handle(handle: i64) -> bool { + handle != 0 && ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) +} + +/// Resolve either a native `AsyncResource` handle or the ordinary object used +/// for a source-compiled subclass to its native backing allocation. +pub(crate) fn resolve_async_resource_handle(receiver: i64) -> Option { + if is_async_resource_handle(receiver) { + return Some(receiver); + } + let raw = receiver as usize; + if !crate::value::addr_class::is_plausible_heap_addr(raw) { + return None; + } + let key = js_string_from_bytes( + ASYNC_RESOURCE_SUBCLASS_KEY.as_ptr(), + ASYNC_RESOURCE_SUBCLASS_KEY.len() as u32, + ); + let value = js_object_get_field_by_name(raw as *const ObjectHeader, key); + if !value.is_pointer() { + return None; + } + let backing = value.as_pointer::() as i64; + is_async_resource_handle(backing).then_some(backing) } #[inline(always)] @@ -226,8 +270,20 @@ pub extern "C" fn js_async_hooks_execution_async_resource() -> f64 { } } + let cached = TOP_LEVEL_RESOURCE.load(Ordering::Acquire); + if cached != 0 { + return f64::from_bits(cached); + } + + // Node exposes one stable bootstrap resource for the top-level execution + // scope. Returning a fresh object here made restoration checks fail after + // every nested AsyncResource scope and also broke metadata inheritance in + // init hooks. let obj = crate::object::js_object_alloc(0, 0); - crate::value::js_nanbox_pointer(obj as i64) + let value = crate::value::js_nanbox_pointer(obj as i64); + TOP_LEVEL_RESOURCE.store(value.to_bits(), Ordering::Release); + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + value } const ASYNC_WRAP_PROVIDER_CONSTANTS: &[(&str, f64)] = &[ @@ -308,7 +364,8 @@ pub fn js_async_hooks_async_wrap_providers() -> f64 { return f64::from_bits(cached); } - let obj = crate::object::js_object_alloc(0, ASYNC_WRAP_PROVIDER_CONSTANTS.len() as u32); + let obj = + crate::object::js_object_alloc_null_proto(0, ASYNC_WRAP_PROVIDER_CONSTANTS.len() as u32); for (name, value) in ASYNC_WRAP_PROVIDER_CONSTANTS { let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); crate::object::js_object_set_field_by_name(obj, key, *value); @@ -483,17 +540,38 @@ pub extern "C" fn js_async_hook_enable(handle: i64) -> i64 { return handle; } let hook = unsafe { &*(handle as *const AsyncHookHandle) }; + if HOOK_CALLBACK_DEPTH.with(Cell::get) != 0 { + PENDING_HOOK_STATES.with(|pending| { + pending.borrow_mut().insert(hook.index, true); + }); + return handle; + } + set_hook_enabled(hook.index, true); + handle +} + +fn set_hook_enabled(index: usize, enabled: bool) { let mut hooks = HOOKS.lock().unwrap(); - if let Some(record) = hooks.get_mut(hook.index) { - if !record.enabled && record.callbacks.has_any() { - HOOKS_ACTIVE.fetch_add(1, Ordering::Relaxed); - if record.track_promises { - PROMISE_HOOKS_ACTIVE.fetch_add(1, Ordering::Relaxed); + if let Some(record) = hooks.get_mut(index) { + if record.enabled == enabled { + return; + } + let delta_is_visible = record.callbacks.has_any(); + if delta_is_visible { + if enabled { + HOOKS_ACTIVE.fetch_add(1, Ordering::Relaxed); + if record.track_promises { + PROMISE_HOOKS_ACTIVE.fetch_add(1, Ordering::Relaxed); + } + } else { + HOOKS_ACTIVE.fetch_sub(1, Ordering::Relaxed); + if record.track_promises { + PROMISE_HOOKS_ACTIVE.fetch_sub(1, Ordering::Relaxed); + } } } - record.enabled = true; + record.enabled = enabled; } - handle } #[no_mangle] @@ -502,16 +580,13 @@ pub extern "C" fn js_async_hook_disable(handle: i64) -> i64 { return handle; } let hook = unsafe { &*(handle as *const AsyncHookHandle) }; - let mut hooks = HOOKS.lock().unwrap(); - if let Some(record) = hooks.get_mut(hook.index) { - if record.enabled && record.callbacks.has_any() { - HOOKS_ACTIVE.fetch_sub(1, Ordering::Relaxed); - if record.track_promises { - PROMISE_HOOKS_ACTIVE.fetch_sub(1, Ordering::Relaxed); - } - } - record.enabled = false; + if HOOK_CALLBACK_DEPTH.with(Cell::get) != 0 { + PENDING_HOOK_STATES.with(|pending| { + pending.borrow_mut().insert(hook.index, false); + }); + return handle; } + set_hook_enabled(hook.index, false); handle } @@ -536,35 +611,81 @@ fn with_hook_callbacks( if !hooks_active() { return; } - IN_HOOK_CALLBACK.with(|guard| { - if guard.get() { - return; - } - guard.set(true); - let callbacks = enabled_callbacks(is_promise); - - // Hook membership is snapshotted once per lifecycle phase: disabling - // a hook from another hook callback must not remove it from the phase - // already in progress, and enabling one must not add it. The callback - // pointers in that snapshot still have to remain moving-GC roots, - // though. A callback can allocate arbitrary JS objects; previously the - // first hook could therefore evacuate the remaining hooks while their - // copied raw pointers stayed in this Rust Vec. Dispatching the next - // stale pointer caused the multi-hook #6764 fixtures to segfault. - let scope = crate::gc::RuntimeHandleScope::new(); - let rooted: Vec<_> = callbacks - .iter() - .map(|callbacks| scope.root_raw_const_ptr(callbacks.for_phase(phase))) - .collect(); - for callback in rooted { - callback.with_const_ptr::(|callback| { - if !callback.is_null() { + let callbacks = enabled_callbacks(is_promise); + HOOK_CALLBACK_DEPTH.with(|depth| depth.set(depth.get() + 1)); + + // Hook membership is snapshotted once per lifecycle phase: disabling a + // hook from another hook callback must not remove it from the phase already + // in progress, and enabling one must not add it. Re-entrant lifecycle + // delivery is nevertheless required: an async operation started by an + // init/destroy callback is a new phase with a fresh membership snapshot. + // A process-wide "inside a hook" guard used to suppress those nested + // phases entirely. + // + // The callback pointers in this snapshot still have to remain moving-GC + // roots. A callback can allocate arbitrary JS objects; without the handles + // the first hook could evacuate the remaining hooks while their copied raw + // pointers stayed stale in this Rust Vec. + let scope = crate::gc::RuntimeHandleScope::new(); + let rooted: Vec<_> = callbacks + .iter() + .map(|callbacks| scope.root_raw_const_ptr(callbacks.for_phase(phase))) + .collect(); + let mut thrown = None; + for callback in rooted { + let outcome = callback.with_const_ptr::(|callback| { + if !callback.is_null() { + 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; } - guard.set(false); + } + let outermost = HOOK_CALLBACK_DEPTH.with(|depth| { + let next = depth.get().saturating_sub(1); + depth.set(next); + next == 0 }); + if outermost { + let pending = PENDING_HOOK_STATES.with(|states| std::mem::take(&mut *states.borrow_mut())); + for (index, enabled) in pending { + 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 +/// compiled entry does not allocate that wrapper Promise, but its init event +/// is observable by hooks enabled during module evaluation. +pub(crate) fn init_esm_evaluation_promise() { + if !promise_hooks_active() { + return; + } + let resource = crate::object::js_object_alloc_null_proto(0, 0); + let value = crate::value::js_nanbox_pointer(resource as i64); + let _ = init_resource("PROMISE", value, false); +} + +/// Reserve an async id for a resource whose lifecycle is not observable yet. +/// +/// Promises created before the first hook is enabled still need a stable id so +/// a later child reaction can name that promise as its trigger. They must not +/// be inserted into `RESOURCES`, because doing so would turn the resource value +/// into a strong GC root before any observer exists. +pub fn reserve_resource_ids(trigger_async_id: u64) -> AsyncResourceIds { + AsyncResourceIds { + async_id: NEXT_ASYNC_ID.fetch_add(1, Ordering::Relaxed), + trigger_async_id, + } } pub fn init_resource(type_name: &str, resource: f64, force_allocate: bool) -> AsyncResourceIds { @@ -590,13 +711,29 @@ pub fn init_resource_with_trigger( } let async_id = NEXT_ASYNC_ID.fetch_add(1, Ordering::Relaxed); + // Native resources such as an accepted TCP socket can be materialized by + // the main-thread pump after the originating callback has returned. At + // that point Perry's current execution id is the bootstrap scope even + // though the provider has an explicit trigger. Inherit the trigger's + // captured store in that narrow case so AsyncLocalStorage crosses the + // native hand-off just as it does in Node. + let context = if execution_async_id_u64() == 0 && trigger_async_id != 0 { + RESOURCES + .lock() + .unwrap() + .get(&trigger_async_id) + .map(|meta| meta.context.clone()) + .unwrap_or_else(crate::async_context::capture_context) + } else { + crate::async_context::capture_context() + }; RESOURCES.lock().unwrap().insert( async_id, ResourceMeta { type_name: type_name.to_string(), trigger_async_id, resource, - context: crate::async_context::capture_context(), + context, destroyed: false, }, ); @@ -702,8 +839,7 @@ fn destroy_with_kind(async_id: u64, is_promise: bool) { meta.destroyed = true; true } - Some(_) => false, - None => true, + Some(_) | None => false, } }; if !should_emit { @@ -719,10 +855,81 @@ pub fn destroy(async_id: u64) { destroy_with_kind(async_id, false); } +/// Explicit `AsyncResource.emitDestroy()` notification. Unlike native +/// provider teardown, Node does not make this API idempotent: every call emits +/// a destroy hook for the resource id. Remove the tracked metadata after the +/// first notification, but continue delivering later explicit notifications. +fn emit_explicit_destroy(async_id: u64) { + if async_id == 0 { + return; + } + with_hook_callbacks(HookPhase::Destroy, false, |callback| { + js_closure_call1(callback, async_id as f64); + }); + RESOURCES.lock().unwrap().remove(&async_id); +} + pub fn destroy_promise(async_id: u64) { destroy_with_kind(async_id, true); } +/// Run a synchronous native completion as an observable async-hooks provider. +/// The operation may already have done its blocking work eagerly, but its +/// Promise settlement still needs the same provider execution/resource scope +/// Node gives a libuv completion. The returned JS value is rooted across hook +/// callbacks, which are arbitrary allocating user code. +pub fn run_provider_completion(type_name: &'static str, completion: impl FnOnce() -> f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = crate::object::js_object_alloc_null_proto(0, 0); + let resource_handle = scope.root_raw_mut_ptr(resource); + let ids = resource_handle.with_mut_ptr::(|resource| { + init_resource( + type_name, + crate::value::js_nanbox_pointer(resource as i64), + true, + ) + }); + before(ids.async_id, ids.trigger_async_id); + let result = scope.root_nanbox_f64(completion()); + after(ids.async_id); + destroy(ids.async_id); + result.get_nanbox_f64() +} + +/// Enter an existing provider's captured AsyncLocalStorage and execution-id +/// scope for one native callback phase. +pub fn enter_resource_scope(ids: AsyncResourceIds) { + let context = RESOURCES + .lock() + .unwrap() + .get(&ids.async_id) + .map(|meta| meta.context.clone()) + .unwrap_or_default(); + let previous = crate::async_context::enter_context(&context); + crate::async_context::push_context_guard( + crate::async_context::ContextGuardAction::RestoreSnapshot(previous), + ); + before(ids.async_id, ids.trigger_async_id); + crate::async_context::push_context_guard( + crate::async_context::ContextGuardAction::RestoreExecutionIds, + ); +} + +/// Leave a provider scope entered by [`enter_resource_scope`]. +pub fn leave_resource_scope(async_id: u64) { + let _ = crate::async_context::pop_context_guard(); + after(async_id); + if let Some(action) = crate::async_context::pop_context_guard() { + crate::async_context::apply_context_guard(action); + } +} + +pub fn run_resource_scope(ids: AsyncResourceIds, completion: impl FnOnce()) { + enter_resource_scope(ids); + completion(); + leave_resource_scope(ids.async_id); +} + pub fn enqueue_gc_destroy(async_id: u64) { if async_id != 0 { GC_DESTROY_QUEUE.lock().unwrap().push_back(async_id); @@ -756,7 +963,7 @@ fn string_header_to_string(ptr: *const StringHeader) -> String { } unsafe { let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); + let data = crate::string::string_data(ptr); String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned() } } @@ -914,12 +1121,22 @@ fn trigger_id_from_options(options: f64) -> u64 { throw_null_trigger_async_id_options(); } + // Node's constructor first validates the option and then consumes it, + // making an accessor observable twice. Preserve that exact ordering; the + // `requireManualDestroy` option is read after both trigger-id reads. + let first_trigger_value = object_field(options, b"triggerAsyncId"); + if !JSValue::from_bits(first_trigger_value.to_bits()).is_undefined() { + let _ = trigger_async_id_or_throw(first_trigger_value); + } let trigger_value = object_field(options, b"triggerAsyncId"); let trigger_value_kind = JSValue::from_bits(trigger_value.to_bits()); - if trigger_value_kind.is_undefined() { - return execution_async_id_u64(); - } - trigger_async_id_or_throw(trigger_value) + let trigger_id = if trigger_value_kind.is_undefined() { + execution_async_id_u64() + } else { + trigger_async_id_or_throw(trigger_value) + }; + let _ = object_field(options, b"requireManualDestroy"); + trigger_id } fn render_apply_value(value: f64) -> String { @@ -997,29 +1214,215 @@ fn validate_bind_callback(value: f64) { #[no_mangle] pub extern "C" fn js_async_resource_new(type_value: f64, options: f64) -> i64 { + new_async_resource_with_public_value(type_value, options, None) +} + +fn new_async_resource_with_public_value( + type_value: f64, + options: f64, + public_resource: Option, +) -> i64 { let scope = crate::gc::RuntimeHandleScope::new(); let type_handle = scope.root_nanbox_f64(type_value); let options_handle = scope.root_nanbox_f64(options); let type_name = require_string_arg("type", type_handle.get_nanbox_f64()); + if type_name.is_empty() && hooks_active() { + crate::fs::validate::throw_type_error_with_code( + "The \"type\" argument must be a non-empty string", + "ERR_ASYNC_TYPE", + ); + } let trigger_async_id = trigger_id_from_options(options_handle.get_nanbox_f64()); - let ids = init_resource_with_trigger(&type_name, TAG_UNDEFINED_F64, true, trigger_async_id); - let handle = Box::into_raw(Box::new(AsyncResourceHandle { ids })) as i64; + // The public resource object is the constructor handle itself. Allocate it + // before firing init so the fourth callback argument is already the exact + // object returned by `new AsyncResource(...)`, as it is in Node. + let handle = Box::into_raw(Box::new(AsyncResourceHandle { + ids: AsyncResourceIds { + async_id: 0, + trigger_async_id, + }, + event_emitter: 0, + })) as i64; ASYNC_RESOURCE_HANDLES.lock().unwrap().insert(handle); ASYNC_RESOURCE_HANDLE_COUNT.fetch_add(1, Ordering::Relaxed); - let resource_value = crate::value::js_nanbox_pointer(handle); - if let Some(meta) = RESOURCES.lock().unwrap().get_mut(&ids.async_id) { - meta.resource = resource_value; - } + let resource_value = public_resource.unwrap_or_else(|| crate::value::js_nanbox_pointer(handle)); + let ids = init_resource_with_trigger(&type_name, resource_value, true, trigger_async_id); + unsafe { (*(handle as *mut AsyncResourceHandle)).ids = ids }; handle } +/// Initialize the native backing for a source-compiled +/// `class X extends AsyncResource` while keeping the public subclass object as +/// the resource passed to hooks and returned by `executionAsyncResource()`. +#[no_mangle] +pub extern "C" fn js_async_resource_subclass_init( + this_value: f64, + type_value: f64, + options: f64, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let this_handle = scope.root_nanbox_f64(this_value); + let type_handle = scope.root_nanbox_f64(type_value); + let options_handle = scope.root_nanbox_f64(options); + let backing = new_async_resource_with_public_value( + type_handle.get_nanbox_f64(), + options_handle.get_nanbox_f64(), + Some(this_handle.get_nanbox_f64()), + ); + let current_this = this_handle.get_nanbox_f64(); + let raw = crate::value::js_nanbox_get_pointer(current_this) as *mut ObjectHeader; + if !raw.is_null() && crate::value::addr_class::is_plausible_heap_addr(raw as usize) { + let key = js_string_from_bytes( + ASYNC_RESOURCE_SUBCLASS_KEY.as_ptr(), + ASYNC_RESOURCE_SUBCLASS_KEY.len() as u32, + ); + crate::object::js_object_set_field_by_name( + raw, + key, + crate::value::js_nanbox_pointer(backing), + ); + for (name, length) in [ + ("asyncId", 0), + ("triggerAsyncId", 0), + ("emitDestroy", 0), + ("runInAsyncScope", 2), + ("bind", 2), + ] { + let method = if name == "bind" { + async_resource_bind_method_value(backing) + } else { + crate::object::async_resource_prototype_method_value(name, length) + }; + let method_handle = scope.root_nanbox_f64(method); + let method_key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + let current_this = this_handle.get_nanbox_f64(); + let current_raw = + crate::value::js_nanbox_get_pointer(current_this) as *mut ObjectHeader; + crate::object::js_object_set_field_by_name( + current_raw, + method_key, + method_handle.get_nanbox_f64(), + ); + crate::object::set_builtin_property_attrs( + current_raw as usize, + name.to_string(), + crate::object::PropertyAttrs::new(true, false, true), + ); + } + } + this_handle.get_nanbox_f64() +} + +/// Link the backing AsyncResource owned by EventEmitterAsyncResource to its +/// public emitter. Node exposes this as `emitter.asyncResource.eventEmitter`. +/// Both sides are stable native handles, so the link does not need GC rooting. +pub fn set_async_resource_event_emitter(handle: i64, event_emitter: i64) { + if handle == 0 || !ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) { + return; + } + unsafe { (*(handle as *mut AsyncResourceHandle)).event_emitter = event_emitter }; +} + +#[no_mangle] +pub extern "C" fn js_async_resource_set_event_emitter(handle: i64, event_emitter: i64) { + set_async_resource_event_emitter(handle, event_emitter); +} + +/// Node exposes `AsyncResource#bind` as an instance-bound function: unlike +/// the other prototype methods, extracting it and calling it later keeps the +/// originating resource as its receiver. Use a dedicated rest trampoline +/// instead of the generic class-method reifier, whose ordinary semantics use +/// the call-site `this` for a detached function. +extern "C" fn async_resource_bind_method_trampoline( + closure: *const ClosureHeader, + rest: f64, +) -> f64 { + if closure.is_null() { + return TAG_UNDEFINED_F64; + } + let handle = js_closure_get_capture_ptr(closure, 0); + let args_array = crate::value::js_nanbox_get_pointer(rest) as *const ArrayHeader; + let args_len = if args_array.is_null() { + 0 + } else { + js_array_length(args_array) + }; + let callback = if args_len == 0 { + TAG_UNDEFINED_F64 + } else { + crate::array::js_array_get_f64(args_array, 0) + }; + let this_arg = if args_len < 2 { + TAG_UNDEFINED_F64 + } else { + crate::array::js_array_get_f64(args_array, 1) + }; + let bound = js_async_resource_bind(handle, callback, this_arg); + if bound == 0 { + TAG_UNDEFINED_F64 + } else { + crate::value::js_nanbox_pointer(bound) + } +} + +fn async_resource_bind_method_value(handle: i64) -> f64 { + let trampoline = async_resource_bind_method_trampoline as *const u8; + js_register_closure_rest(trampoline, 0); + let closure = js_closure_alloc(trampoline, 1); + if closure.is_null() { + return TAG_UNDEFINED_F64; + } + js_closure_set_capture_ptr(closure, 0, handle); + crate::object::set_builtin_closure_length(closure as usize, 2); + crate::object::set_bound_native_closure_name(closure, "bind"); + crate::value::js_nanbox_pointer(closure as i64) +} + +pub fn try_async_resource_property_dispatch(handle: i64, property: &str) -> Option { + if ASYNC_RESOURCE_HANDLE_COUNT.load(Ordering::Relaxed) == 0 + || !ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) + { + return None; + } + // User-defined own properties shadow AsyncResource.prototype just as they + // do on Node's ordinary public resource object. The backing allocation is + // a native Box, so keep expandos in the same traced side table used by + // small native handles rather than ever treating it as an ObjectHeader. + if let Some(value) = crate::object::handle_expando::handle_expando_get(handle, property) { + return Some(value); + } + if property == "bind" { + return Some(async_resource_bind_method_value(handle)); + } + if let Some((name, length)) = match property { + "asyncId" => Some(("asyncId", 0)), + "triggerAsyncId" => Some(("triggerAsyncId", 0)), + "emitDestroy" => Some(("emitDestroy", 0)), + "runInAsyncScope" => Some(("runInAsyncScope", 2)), + _ => None, + } { + return Some(crate::object::async_resource_prototype_method_value( + name, length, + )); + } + if property != "eventEmitter" { + return None; + } + let emitter = unsafe { (*(handle as *const AsyncResourceHandle)).event_emitter }; + Some(if emitter == 0 { + TAG_UNDEFINED_F64 + } else { + crate::value::js_nanbox_pointer(emitter) + }) +} + /// Dynamic method dispatch for `AsyncResource` receivers whose static type /// the codegen lost (closure-captured / `any`-typed bindings). Registry /// membership is checked before any dereference, so a genuine heap object /// can never be claimed. Returns `None` when the receiver is not a live /// AsyncResource handle or the method name is not part of its vocabulary. pub fn try_async_resource_method_dispatch( - handle: i64, + receiver: i64, method_name: &str, args_ptr: *const f64, args_len: usize, @@ -1033,9 +1436,6 @@ pub fn try_async_resource_method_dispatch( ) { return None; } - if !ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) { - return None; - } let scope = crate::gc::RuntimeHandleScope::new(); let raw_args: Vec = if args_ptr.is_null() || args_len == 0 { Vec::new() @@ -1043,13 +1443,14 @@ 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), "triggerAsyncId" => js_async_resource_trigger_async_id(handle), "emitDestroy" => { js_async_resource_emit_destroy(handle); - crate::value::js_nanbox_pointer(handle) + crate::value::js_nanbox_pointer(receiver) } "runInAsyncScope" => { // runInAsyncScope(fn[, thisArg, ...args]) @@ -1094,27 +1495,27 @@ fn pack_rest_args_array(rest: &[f64]) -> i64 { #[no_mangle] pub extern "C" fn js_async_resource_async_id(handle: i64) -> f64 { - if handle == 0 { + let Some(handle) = resolve_async_resource_handle(handle) else { return 0.0; - } + }; let resource = unsafe { &*(handle as *const AsyncResourceHandle) }; resource.ids.async_id as f64 } #[no_mangle] pub extern "C" fn js_async_resource_trigger_async_id(handle: i64) -> f64 { - if handle == 0 { + let Some(handle) = resolve_async_resource_handle(handle) else { return 0.0; - } + }; let resource = unsafe { &*(handle as *const AsyncResourceHandle) }; async_id_to_js_number(resource.ids.trigger_async_id) } #[no_mangle] pub extern "C" fn js_async_resource_emit_destroy(handle: i64) -> i64 { - if handle != 0 { - let resource = unsafe { &*(handle as *const AsyncResourceHandle) }; - destroy(resource.ids.async_id); + if let Some(backing) = resolve_async_resource_handle(handle) { + let resource = unsafe { &*(backing as *const AsyncResourceHandle) }; + emit_explicit_destroy(resource.ids.async_id); } handle } @@ -1126,9 +1527,9 @@ pub extern "C" fn js_async_resource_run_in_async_scope( this_arg: f64, args_array: i64, ) -> f64 { - if handle == 0 { + let Some(handle) = resolve_async_resource_handle(handle) else { return TAG_UNDEFINED_F64; - } + }; if !is_callable_value(callback_value) { throw_apply_not_function(callback_value); } @@ -1165,20 +1566,29 @@ pub extern "C" fn js_async_resource_run_in_async_scope( crate::async_context::ContextGuardAction::RestoreExecutionIds, ); let prev_this = crate::object::js_implicit_this_set(this_arg_handle.get_nanbox_f64()); - let result = if args_array == 0 { - unsafe { js_closure_call_array(callback as i64, ptr::null(), 0) } - } else { - let arr = args_array_handle.get_raw_const_ptr::(); - let len = js_array_length(arr) as i64; - let data = if arr.is_null() { - ptr::null() + // Catch locally so a throwing scope still delivers `after` and restores + // the resource/context before the exception is rethrown to user code. + // The trap is installed after our guards, so throw-time unwinding leaves + // those guards for the normal cleanup below. + let outcome = crate::exception::js_call_catching(|| { + if args_array == 0 { + unsafe { js_closure_call_array(callback as i64, ptr::null(), 0) } } else { - unsafe { (arr as *const u8).add(std::mem::size_of::()) as *const f64 } - }; - unsafe { js_closure_call_array(callback as i64, data, len) } - }; + let arr = args_array_handle.get_raw_const_ptr::(); + let len = js_array_length(arr) as i64; + let data = if arr.is_null() { + ptr::null() + } else { + unsafe { (arr as *const u8).add(std::mem::size_of::()) as *const f64 } + }; + unsafe { js_closure_call_array(callback as i64, data, len) } + } + }); crate::object::js_implicit_this_set(prev_this); - let result_handle = scope.root_nanbox_f64(result); + let threw = outcome.is_err(); + let result_handle = scope.root_nanbox_f64(match outcome { + Ok(result) | Err(result) => result, + }); // Normal exit: `after` fires hooks and pops the execution scope itself, // so discard the silent-unwind guard rather than applying it. let _ = crate::async_context::pop_context_guard(); @@ -1190,6 +1600,9 @@ pub extern "C" fn js_async_resource_run_in_async_scope( if let Some(action) = crate::async_context::pop_context_guard() { crate::async_context::apply_context_guard(action); } + if threw { + crate::exception::js_throw(result_handle.get_nanbox_f64()); + } result_handle.get_nanbox_f64() } @@ -1233,9 +1646,9 @@ fn register_bind_trampoline_once() { #[no_mangle] pub extern "C" fn js_async_resource_bind(handle: i64, callback_value: f64, this_arg: f64) -> i64 { validate_bind_callback(callback_value); - if handle == 0 { + let Some(handle) = resolve_async_resource_handle(handle) else { return 0; - } + }; register_bind_trampoline_once(); let scope = crate::gc::RuntimeHandleScope::new(); let callback_handle = scope.root_nanbox_f64(callback_value); @@ -1302,7 +1715,21 @@ pub extern "C" fn js_async_resource_static_bind_value( let scope = crate::gc::RuntimeHandleScope::new(); let callback_handle = scope.root_nanbox_f64(callback_value); let type_value = if JSValue::from_bits(type_value.to_bits()).is_undefined() { - let default_type = b"AsyncResource"; + let callback = crate::fs::extract_closure_ptr(callback_handle.get_nanbox_f64()); + let inferred = if callback.is_null() { + None + } else { + let own_name = crate::closure::closure_get_dynamic_prop(callback as usize, "name"); + let own_name = JSValue::from_bits(own_name.to_bits()); + if own_name.is_any_string() { + let name = js_string_value_to_string(f64::from_bits(own_name.bits())); + (!name.is_empty()).then_some(name) + } else { + unsafe { crate::builtins::function_name_for_ptr((*callback).func_ptr as usize) } + .filter(|name| !name.is_empty()) + } + }; + let default_type = inferred.as_deref().unwrap_or("bound-anonymous-fn"); box_string( js_string_from_bytes(default_type.as_ptr(), default_type.len() as u32) as *const u8, ) @@ -1431,9 +1858,11 @@ extern "C" fn async_local_storage_snapshot_trampoline( rest: f64, ) -> f64 { let snapshot_id = js_closure_get_capture_ptr(closure, 0) as usize; - let this_arg = crate::object::js_implicit_this_get(); run_with_context_snapshot(snapshot_id, || { - call_callback_with_rest(callback_value, this_arg, rest) + // AsyncLocalStorage.snapshot() intentionally invokes the supplied + // callback as a plain function. The receiver used to call the snapshot + // wrapper itself is not forwarded. + call_callback_with_rest(callback_value, TAG_UNDEFINED_F64, rest) }) } @@ -1491,7 +1920,11 @@ pub fn scan_async_hooks_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_ drop(hooks); let mut resources = RESOURCES.lock().unwrap(); for meta in resources.values_mut() { - visitor.visit_nanbox_f64_slot(&mut meta.resource); + // Resource identity is weak: the resource's owning scheduler/promise + // keeps it alive, while its finalizer enqueues the destroy event and + // removes this metadata. Marking the value here made PROMISE entries + // immortal and forced the runtime to fake destroy-at-settlement. + visitor.visit_metadata_nanbox_f64_slot(&mut meta.resource); crate::async_context::scan_snapshot_roots_mut(&mut meta.context, visitor); } drop(resources); @@ -1507,171 +1940,19 @@ pub fn scan_async_hooks_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_ visitor.visit_nanbox_u64_slot(&mut providers_bits); ASYNC_WRAP_PROVIDERS.store(providers_bits, Ordering::Relaxed); } -} -#[cfg(test)] -pub fn reset_for_tests() { - HOOKS.lock().unwrap().clear(); - RESOURCES.lock().unwrap().clear(); - GC_DESTROY_QUEUE.lock().unwrap().clear(); - CONTEXT_SNAPSHOTS.lock().unwrap().clear(); - ASYNC_WRAP_PROVIDERS.store(0, Ordering::Relaxed); - HOOKS_ACTIVE.store(0, Ordering::Relaxed); - PROMISE_HOOKS_ACTIVE.store(0, Ordering::Relaxed); - NEXT_ASYNC_ID.store(2, Ordering::Relaxed); - NEXT_CONTEXT_SNAPSHOT_ID.store(1, Ordering::Relaxed); - CURRENT_EXECUTION_ID.with(|c| c.set(0)); - CURRENT_TRIGGER_ID.with(|c| c.set(0)); - IN_HOOK_CALLBACK.with(|c| c.set(false)); - EXECUTION_STACK.with(|s| s.borrow_mut().clear()); + let mut top_level_bits = TOP_LEVEL_RESOURCE.load(Ordering::Relaxed); + if top_level_bits != 0 { + visitor.visit_nanbox_u64_slot(&mut top_level_bits); + TOP_LEVEL_RESOURCE.store(top_level_bits, Ordering::Relaxed); + } } #[cfg(test)] -pub(crate) fn test_seed_async_hooks_scanner_roots(callback: *const ClosureHeader, resource: f64) { - reset_for_tests(); - HOOKS.lock().unwrap().push(HookRecord { - callbacks: HookCallbacks { - init: callback, - before: callback, - after: callback, - destroy: callback, - promise_resolve: callback, - }, - enabled: true, - track_promises: true, - }); - HOOKS_ACTIVE.store(1, Ordering::Relaxed); - PROMISE_HOOKS_ACTIVE.store(1, Ordering::Relaxed); - RESOURCES.lock().unwrap().insert( - 1, - ResourceMeta { - type_name: "test".to_string(), - trigger_async_id: 0, - resource, - context: crate::async_context::AsyncContextSnapshot::default(), - destroyed: false, - }, - ); -} - +mod test_support; #[cfg(test)] -pub(crate) fn test_async_hooks_scanner_snapshot() -> (usize, u64) { - let callback = HOOKS - .lock() - .unwrap() - .first() - .map(|hook| hook.callbacks.init as usize) - .unwrap_or(0); - let resource_bits = RESOURCES - .lock() - .unwrap() - .get(&1) - .map(|meta| meta.resource.to_bits()) - .unwrap_or(0); - (callback, resource_bits) -} - +pub use test_support::reset_for_tests; #[cfg(test)] -mod tests { - use super::*; - - // #7680: no lock needed here anymore. `NEXT_ASYNC_ID` / `HOOKS` / - // `RESOURCES` / etc. are `per_test_global!`, so this thread's - // `reset_for_tests()` and `init_resource` calls touch only this thread's - // own instances — a concurrent test on another thread cannot land a - // `+2` between the two `init_resource` calls below, which is exactly - // the #7672 shape this test's own docstring warns about (a wrong VALUE, - // not a hang). - - #[test] - fn resource_ids_are_monotonic_even_without_hooks() { - reset_for_tests(); - let a = init_resource("A", TAG_UNDEFINED_F64, true); - let b = init_resource("B", TAG_UNDEFINED_F64, true); - // Ids start above 1 (Node reserves 1 for the root context) and are - // monotonic. - assert!(a.async_id > 1); - assert_eq!(b.async_id, a.async_id + 1); - } - - #[test] - fn before_after_restore_execution_ids() { - reset_for_tests(); - let ids = init_resource("A", TAG_UNDEFINED_F64, true); - before(ids.async_id, ids.trigger_async_id); - assert_eq!(execution_async_id_u64(), ids.async_id); - after(ids.async_id); - assert_eq!(execution_async_id_u64(), 0); - } - - #[test] - fn track_promises_filters_hooks_and_activity() { - reset_for_tests(); - let mut callbacks = HookCallbacks::empty(); - callbacks.init = std::ptr::NonNull::::dangling().as_ptr(); - HOOKS.lock().unwrap().extend([ - HookRecord { - callbacks, - enabled: false, - track_promises: false, - }, - HookRecord { - callbacks, - enabled: false, - track_promises: true, - }, - ]); - let suppressed = AsyncHookHandle { index: 0 }; - let tracked = AsyncHookHandle { index: 1 }; - - js_async_hook_enable(&suppressed as *const AsyncHookHandle as i64); - assert!(hooks_active()); - assert!(!promise_hooks_active()); - - js_async_hook_enable(&tracked as *const AsyncHookHandle as i64); - assert!(promise_hooks_active()); - assert_eq!(enabled_callbacks(false).len(), 2); - assert_eq!(enabled_callbacks(true).len(), 1); - - js_async_hook_disable(&tracked as *const AsyncHookHandle as i64); - assert!(hooks_active()); - assert!(!promise_hooks_active()); - js_async_hook_disable(&suppressed as *const AsyncHookHandle as i64); - assert!(!hooks_active()); - reset_for_tests(); - } - - /// #7680: plants the #7672 shape directly rather than relying on a - /// scheduling accident — install a resource on THIS thread, run - /// `reset_for_tests()` (what all four of the pre-fix lock domains - /// eventually call) on ANOTHER thread, and assert the resource survived. - /// Revert the `per_test_global!` conversion above (back to bare - /// `static`s) and this fails with the resource gone — a foreign - /// thread's `reset_for_tests()` wiped `NEXT_ASYNC_ID` out from under a - /// resource this thread had already allocated, id and all. - #[test] - fn async_hooks_state_survives_a_foreign_reset_for_tests() { - reset_for_tests(); - let ids = init_resource("survivor", TAG_UNDEFINED_F64, true); - assert!( - RESOURCES.lock().unwrap().contains_key(&ids.async_id), - "the probe installed nothing, so survived-vs-wiped would be vacuous" - ); - - std::thread::spawn(reset_for_tests) - .join() - .expect("the clearing thread panicked"); - - assert!( - RESOURCES.lock().unwrap().contains_key(&ids.async_id), - "a resource installed on this thread was destroyed by a GC test guard's \ - async_hooks reset running on another thread (#7680). Per-thread storage \ - (`per_test_global!`) is what prevents this." - ); - assert!( - NEXT_ASYNC_ID.load(Ordering::Relaxed) > ids.async_id, - "NEXT_ASYNC_ID must not have been rewound by the foreign reset either" - ); - reset_for_tests(); - } -} +pub(crate) use test_support::{ + test_async_hooks_scanner_snapshot, test_seed_async_hooks_scanner_roots, +}; 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/async_hooks/test_support.rs b/crates/perry-runtime/src/async_hooks/test_support.rs new file mode 100644 index 0000000000..be53907020 --- /dev/null +++ b/crates/perry-runtime/src/async_hooks/test_support.rs @@ -0,0 +1,161 @@ +use super::*; + +pub fn reset_for_tests() { + HOOKS.lock().unwrap().clear(); + RESOURCES.lock().unwrap().clear(); + GC_DESTROY_QUEUE.lock().unwrap().clear(); + CONTEXT_SNAPSHOTS.lock().unwrap().clear(); + ASYNC_WRAP_PROVIDERS.store(0, Ordering::Relaxed); + TOP_LEVEL_RESOURCE.store(0, Ordering::Relaxed); + HOOKS_ACTIVE.store(0, Ordering::Relaxed); + PROMISE_HOOKS_ACTIVE.store(0, Ordering::Relaxed); + NEXT_ASYNC_ID.store(2, Ordering::Relaxed); + NEXT_CONTEXT_SNAPSHOT_ID.store(1, Ordering::Relaxed); + CURRENT_EXECUTION_ID.with(|c| c.set(0)); + CURRENT_TRIGGER_ID.with(|c| c.set(0)); + EXECUTION_STACK.with(|s| s.borrow_mut().clear()); +} + +pub(crate) fn test_seed_async_hooks_scanner_roots(callback: *const ClosureHeader, resource: f64) { + reset_for_tests(); + HOOKS.lock().unwrap().push(HookRecord { + callbacks: HookCallbacks { + init: callback, + before: callback, + after: callback, + destroy: callback, + promise_resolve: callback, + }, + enabled: true, + track_promises: true, + }); + HOOKS_ACTIVE.store(1, Ordering::Relaxed); + PROMISE_HOOKS_ACTIVE.store(1, Ordering::Relaxed); + RESOURCES.lock().unwrap().insert( + 1, + ResourceMeta { + type_name: "test".to_string(), + trigger_async_id: 0, + resource, + context: crate::async_context::AsyncContextSnapshot::default(), + destroyed: false, + }, + ); +} + +pub(crate) fn test_async_hooks_scanner_snapshot() -> (usize, u64) { + let callback = HOOKS + .lock() + .unwrap() + .first() + .map(|hook| hook.callbacks.init as usize) + .unwrap_or(0); + let resource_bits = RESOURCES + .lock() + .unwrap() + .get(&1) + .map(|meta| meta.resource.to_bits()) + .unwrap_or(0); + (callback, resource_bits) +} + +#[cfg(test)] +mod tests { + use super::*; + + // #7680: no lock needed here anymore. The per-test globals isolate this + // thread's reset and resource-id sequence from concurrent tests. + #[test] + fn resource_ids_are_monotonic_even_without_hooks() { + reset_for_tests(); + let a = init_resource("A", TAG_UNDEFINED_F64, true); + let b = init_resource("B", TAG_UNDEFINED_F64, true); + assert!(a.async_id > 1); + assert_eq!(b.async_id, a.async_id + 1); + } + + #[test] + fn before_after_restore_execution_ids() { + reset_for_tests(); + let ids = init_resource("A", TAG_UNDEFINED_F64, true); + before(ids.async_id, ids.trigger_async_id); + assert_eq!(execution_async_id_u64(), ids.async_id); + after(ids.async_id); + assert_eq!(execution_async_id_u64(), 0); + } + + #[test] + fn track_promises_filters_hooks_and_activity() { + reset_for_tests(); + let mut callbacks = HookCallbacks::empty(); + callbacks.init = std::ptr::NonNull::::dangling().as_ptr(); + HOOKS.lock().unwrap().extend([ + HookRecord { + callbacks, + enabled: false, + track_promises: false, + }, + HookRecord { + callbacks, + enabled: false, + track_promises: true, + }, + ]); + let suppressed = AsyncHookHandle { index: 0 }; + let tracked = AsyncHookHandle { index: 1 }; + js_async_hook_enable(&suppressed as *const AsyncHookHandle as i64); + assert!(hooks_active()); + assert!(!promise_hooks_active()); + js_async_hook_enable(&tracked as *const AsyncHookHandle as i64); + assert!(promise_hooks_active()); + assert_eq!(enabled_callbacks(false).len(), 2); + assert_eq!(enabled_callbacks(true).len(), 1); + js_async_hook_disable(&tracked as *const AsyncHookHandle as i64); + assert!(hooks_active()); + assert!(!promise_hooks_active()); + js_async_hook_disable(&suppressed as *const AsyncHookHandle as i64); + assert!(!hooks_active()); + reset_for_tests(); + } + + #[test] + fn async_hooks_state_survives_a_foreign_reset_for_tests() { + reset_for_tests(); + let ids = init_resource("survivor", TAG_UNDEFINED_F64, true); + assert!(RESOURCES.lock().unwrap().contains_key(&ids.async_id)); + std::thread::spawn(reset_for_tests) + .join() + .expect("the clearing thread panicked"); + assert!(RESOURCES.lock().unwrap().contains_key(&ids.async_id)); + assert!(NEXT_ASYNC_ID.load(Ordering::Relaxed) > ids.async_id); + reset_for_tests(); + } + + #[test] + fn native_async_resource_accepts_string_and_symbol_expandos() { + reset_for_tests(); + crate::symbol::test_clear_symbol_side_table_roots(); + let type_ptr = js_string_from_bytes(b"ExpandoResource".as_ptr(), 15); + let type_value = crate::value::js_nanbox_string(type_ptr as i64); + let handle = js_async_resource_new(type_value, TAG_UNDEFINED_F64); + assert!(is_async_resource_handle(handle)); + let resource = crate::value::js_nanbox_pointer(handle); + let symbol = unsafe { crate::symbol::js_symbol_new_empty() }; + unsafe { + crate::symbol::js_object_set_symbol_property(resource, symbol, TAG_UNDEFINED_F64); + } + crate::value::js_dyn_index_set(resource, symbol, 41.0); + assert_eq!( + unsafe { crate::symbol::js_object_get_symbol_property(resource, symbol) }.to_bits(), + 41.0f64.to_bits() + ); + let name_ptr = js_string_from_bytes(b"label".as_ptr(), 5); + let name = crate::value::js_nanbox_string(name_ptr as i64); + crate::proxy::js_put_value_set(resource, name, 42.0, resource, 1); + assert_eq!( + try_async_resource_property_dispatch(handle, "label").map(f64::to_bits), + Some(42.0f64.to_bits()) + ); + reset_for_tests(); + } +} diff --git a/crates/perry-runtime/src/child_process/emitter.rs b/crates/perry-runtime/src/child_process/emitter.rs index 312ca96a42..8953d87654 100644 --- a/crates/perry-runtime/src/child_process/emitter.rs +++ b/crates/perry-runtime/src/child_process/emitter.rs @@ -43,6 +43,12 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { return true; } + let async_ids = + cp_handle_of(target).and_then(|handle| reactor::cp_async_scope_for_target(handle, target)); + if let Some(ids) = async_ids { + crate::async_hooks::enter_resource_scope(ids); + } + let key = cp_listener_key(event); let mut i: u32 = 0; let mut fired = false; @@ -72,6 +78,9 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { crate::node_stream::emit_to_stream_listeners(target, event.as_bytes(), args); } + if let Some(ids) = async_ids { + crate::async_hooks::leave_resource_scope(ids.async_id); + } fired } diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index b31ef82be3..dfca1ece2e 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -99,6 +99,9 @@ static CP_EVENT_QUEUE: Mutex> = Mutex::new(Vec::new()); struct LiveChild { /// NaN-boxed ChildProcess object — a GC root (see `cp_reactor_scan_roots_mut`). cp_bits: u64, + process_ids: crate::async_hooks::AsyncResourceIds, + pipe_ids: [crate::async_hooks::AsyncResourceIds; 3], + pipe_bits: [u64; 3], pid: i32, stdin: Option, stdout_open: bool, @@ -196,6 +199,30 @@ pub(super) struct CpExecPending { static CP_LIVE: Mutex>> = Mutex::new(None); +fn cp_init_async_resources( + cp: f64, + stdin_obj: Option, + stdout_obj: f64, + stderr_obj: f64, +) -> ( + crate::async_hooks::AsyncResourceIds, + [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 = [ + stdin_ids, + crate::async_hooks::init_resource("PIPEWRAP", stdout_obj, true), + crate::async_hooks::init_resource("PIPEWRAP", stderr_obj, true), + ]; + (process_ids, pipe_ids) +} + thread_local! { /// Re-entrancy guard — an emitted handler may itself drive the event loop /// (`await`), which re-enters `js_run_stdlib_pump` → `cp_reactor_pump`. @@ -486,6 +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, 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. @@ -504,6 +533,13 @@ fn cp_register_live_child_parts( handle, LiveChild { cp_bits: cp.to_bits(), + process_ids, + pipe_ids, + pipe_bits: [ + stdin_obj.to_bits(), + stdout_obj.to_bits(), + stderr_obj.to_bits(), + ], pid: pid as i32, stdin: stdin_pipe, stdout_open, @@ -1091,6 +1127,7 @@ pub(super) fn cp_exec_async( exceeded: false, timed_out: false, }); + let (process_ids, pipe_ids) = cp_init_async_resources(cp, None, stdout_obj, stderr_obj); { let mut guard = cp_live_lock(); @@ -1099,6 +1136,13 @@ pub(super) fn cp_exec_async( handle, LiveChild { cp_bits: cp.to_bits(), + process_ids, + pipe_ids, + pipe_bits: [ + TAG_NULL_F64.to_bits(), + stdout_obj.to_bits(), + stderr_obj.to_bits(), + ], pid: pid as i32, stdin: None, stdout_open, @@ -1503,6 +1547,8 @@ fn cp_reactor_pump_inner() { abort_signal_bits: lc.abort_signal_bits, abort_listener_bits: lc.abort_listener_bits, exec: lc.exec.take(), + process_ids: lc.process_ids, + pipe_ids: lc.pipe_ids, }); lc.abort_signal_bits = 0; lc.abort_listener_bits = 0; @@ -1524,7 +1570,9 @@ fn cp_reactor_pump_inner() { cp_set_field(cp, b"exitCode", code_f); cp_set_field(cp, b"signalCode", signal_f); cp_emit(cp, "exit", &[code_f, signal_f]); + crate::async_hooks::enter_resource_scope(item.process_ids); cp_exec_fire_close(exec, item.code, item.signal, item.pid); + crate::async_hooks::leave_resource_scope(item.process_ids.async_id); cp_emit(cp, "close", &[code_f, signal_f]); } else { let cp = f64::from_bits(item.cp_bits); @@ -1542,6 +1590,10 @@ fn cp_reactor_pump_inner() { if let Some(map) = cp_live_lock().as_mut() { map.remove(&item.handle); } + crate::async_hooks::destroy(item.process_ids.async_id); + for pipe in item.pipe_ids { + crate::async_hooks::destroy(pipe.async_id); + } CP_LIVE_COUNT.fetch_sub(1, Ordering::SeqCst); } } @@ -1557,6 +1609,8 @@ struct CpCloseItem { abort_signal_bits: u64, abort_listener_bits: u64, exec: Option>, + process_ids: crate::async_hooks::AsyncResourceIds, + pipe_ids: [crate::async_hooks::AsyncResourceIds; 3], } #[inline] @@ -1576,6 +1630,25 @@ fn cp_lookup_cp_bits(handle: u64) -> Option { .and_then(|map| map.get(&handle).map(|lc| lc.cp_bits)) } +pub(super) fn cp_async_scope_for_target( + handle: u64, + target: f64, +) -> Option { + let target_bits = target.to_bits(); + let guard = cp_live_lock(); + let child = guard.as_ref()?.get(&handle)?; + if target_bits == child.cp_bits { + return Some(child.process_ids); + } + child + .pipe_bits + .iter() + .enumerate() + .filter(|(index, bits)| *index != 0 || **bits != TAG_NULL_F64.to_bits()) + .find(|(_, bits)| **bits == target_bits) + .map(|(index, _)| child.pipe_ids[index]) +} + // ============================================================================ // Live `stdin.write()` / `kill()` — called from the mod.rs method bodies. // ============================================================================ @@ -1855,133 +1928,6 @@ pub(crate) fn cp_reactor_scan_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi } } -// ============================================================================ -// Tests -// ============================================================================ - -/// Windows termination tests. `child.kill()`, spawn `{ timeout }`, -/// `AbortSignal`, and the exec `maxBuffer` breach all funnel through -/// `cp_live_kill_signum` / `cp_live_kill_signal` → `cp_win_kill`, so -/// terminating one live child through the shared path exercises the machinery -/// all of them rely on. #[cfg(all(test, windows))] -mod windows_kill_tests { - use super::*; - - /// Raw `cp_win_dup_proc_handle` + `cp_win_kill`: sig-0 existence probe on - /// a live child, terminate through the duplicated handle, probe + kill - /// failure after death. The held duplicate keeps naming the original - /// process object even once the child is reaped — exactly the property - /// that closes the pid-reuse race. - #[test] - fn win_kill_probe_and_terminate() { - let mut child = std::process::Command::new("ping") - .args(["-n", "30", "127.0.0.1"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ping"); - let proc_handle = cp_win_dup_proc_handle(&child); - assert_ne!(proc_handle, 0, "DuplicateHandle should succeed"); - - // POSIX `kill(pid, 0)` analogue: existence probe, no side effect. - assert!( - cp_win_kill(proc_handle, 0), - "probe should see the live child" - ); - - // Any terminating signal degrades to `TerminateProcess(handle, 1)`. - assert!(cp_win_kill(proc_handle, 15), "terminate should succeed"); - let status = child.wait().expect("wait after TerminateProcess"); - assert_eq!(status.code(), Some(1), "TerminateProcess exit code"); - - // The duplicate still names the original (now-dead) process after the - // reap, so both the probe and a second kill deterministically fail — - // no pid-recycling flake window exists for a handle. - assert!(!cp_win_kill(proc_handle, 0), "probe should fail once dead"); - assert!( - !cp_win_kill(proc_handle, 15), - "kill after death reports undelivered" - ); - - // The test owns this duplicate (no LiveChild registry entry) — close - // it by hand. - unsafe { - let _ = windows_sys::Win32::Foundation::CloseHandle( - proc_handle as windows_sys::Win32::Foundation::HANDLE, - ); - } - } - - /// Registry-level liveness: the pump removes the entry once the child has - /// fully closed (exit reported + both streams at EOF). - fn handle_is_live(handle: u64) -> bool { - cp_live_lock() - .as_ref() - .is_some_and(|map| map.contains_key(&handle)) - } - - /// End-to-end through the reactor: spawn a long-running child via the - /// spawn FFI, terminate it through the same shared path `child.kill()` / - /// `{ timeout }` / `AbortSignal` use, and drive the pump until the exit - /// machinery completes. Asserts the Node-shaped exit for a Windows kill: - /// `exitCode: null`, `signalCode: 'SIGTERM'`. - #[test] - fn reactor_kill_terminates_live_child() { - // `ping -n 30 127.0.0.1` runs ~29s if not killed — long enough that a - // pass can only come from the kill path, short enough to bound a - // failure without hanging the suite. - let cmd = "ping"; - let cmd_ptr = crate::string::js_string_from_bytes(cmd.as_ptr(), cmd.len() as u32); - let mut args = crate::array::js_array_alloc(3); - for a in ["-n", "30", "127.0.0.1"] { - let s = crate::string::js_string_from_bytes(a.as_ptr(), a.len() as u32); - args = crate::array::js_array_push_f64(args, crate::value::js_nanbox_string(s as i64)); - } - let start = std::time::Instant::now(); - let cp = js_child_process_spawn_streams(cmd_ptr as i64, args as i64, 0); - - let pid = cp_get_field(cp, b"pid"); - assert!(pid > 0.0, "spawn should set a real pid, got {pid}"); - let handle = cp_get_field(cp, b"__cpHandle") as u64; - assert!(handle_is_live(handle)); - - // Phase 0 marks `spawned` — Phase B refuses to close before that. - cp_reactor_pump(); - - // Kill through the shared path (undefined signal → SIGTERM). - assert!( - cp_live_kill(handle, cp_undefined()), - "kill should be delivered" - ); - - // Drive the pump until the exit machinery completes: waiter reaps → - // `Exited` event → exit/close emitted → registry entry removed. - let deadline = std::time::Instant::now() + Duration::from_secs(15); - while handle_is_live(handle) { - assert!( - std::time::Instant::now() < deadline, - "child did not close within 15s of kill()" - ); - cp_reactor_pump(); - std::thread::sleep(Duration::from_millis(10)); - } - // Well under ping's ~29s natural runtime — it died from the kill. - assert!(start.elapsed() < Duration::from_secs(20)); - - // Node's Windows kill shape: exitCode null, signalCode 'SIGTERM' - // (the requested signal, not TerminateProcess's synthetic exit code). - assert_eq!( - cp_get_field(cp, b"exitCode").to_bits(), - TAG_NULL_F64.to_bits(), - "exitCode should be null after a signal kill" - ); - assert_eq!( - cp_value_to_string(cp_get_field(cp, b"signalCode")).as_deref(), - Some("SIGTERM") - ); - - // The child is reaped and deregistered — a second kill reports false. - assert!(!cp_live_kill(handle, cp_undefined())); - } -} +#[path = "reactor/windows_kill_tests.rs"] +mod windows_kill_tests; diff --git a/crates/perry-runtime/src/child_process/reactor/windows_kill_tests.rs b/crates/perry-runtime/src/child_process/reactor/windows_kill_tests.rs new file mode 100644 index 0000000000..7813c9d068 --- /dev/null +++ b/crates/perry-runtime/src/child_process/reactor/windows_kill_tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn win_kill_probe_and_terminate() { + let mut child = std::process::Command::new("ping") + .args(["-n", "30", "127.0.0.1"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ping"); + let proc_handle = cp_win_dup_proc_handle(&child); + assert_ne!(proc_handle, 0, "DuplicateHandle should succeed"); + assert!(cp_win_kill(proc_handle, 0)); + assert!(cp_win_kill(proc_handle, 15)); + let status = child.wait().expect("wait after TerminateProcess"); + assert_eq!(status.code(), Some(1)); + assert!(!cp_win_kill(proc_handle, 0)); + assert!(!cp_win_kill(proc_handle, 15)); + unsafe { + let _ = windows_sys::Win32::Foundation::CloseHandle( + proc_handle as windows_sys::Win32::Foundation::HANDLE, + ); + } +} + +fn handle_is_live(handle: u64) -> bool { + cp_live_lock() + .as_ref() + .is_some_and(|map| map.contains_key(&handle)) +} + +#[test] +fn reactor_kill_terminates_live_child() { + let cmd = "ping"; + let cmd_ptr = crate::string::js_string_from_bytes(cmd.as_ptr(), cmd.len() as u32); + let mut args = crate::array::js_array_alloc(3); + for a in ["-n", "30", "127.0.0.1"] { + let s = crate::string::js_string_from_bytes(a.as_ptr(), a.len() as u32); + args = crate::array::js_array_push_f64(args, crate::value::js_nanbox_string(s as i64)); + } + let start = std::time::Instant::now(); + let cp = js_child_process_spawn_streams(cmd_ptr as i64, args as i64, 0); + let pid = cp_get_field(cp, b"pid"); + assert!(pid > 0.0, "spawn should set a real pid, got {pid}"); + let handle = cp_get_field(cp, b"__cpHandle") as u64; + assert!(handle_is_live(handle)); + cp_reactor_pump(); + assert!(cp_live_kill(handle, cp_undefined())); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while handle_is_live(handle) { + assert!(std::time::Instant::now() < deadline); + cp_reactor_pump(); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(start.elapsed() < Duration::from_secs(20)); + assert_eq!( + cp_get_field(cp, b"exitCode").to_bits(), + TAG_NULL_F64.to_bits() + ); + assert_eq!( + cp_value_to_string(cp_get_field(cp, b"signalCode")).as_deref(), + Some("SIGTERM") + ); + assert!(!cp_live_kill(handle, cp_undefined())); +} diff --git a/crates/perry-runtime/src/closure/dispatch/value_call.rs b/crates/perry-runtime/src/closure/dispatch/value_call.rs index dc9b934c99..5dc56cd58b 100644 --- a/crates/perry-runtime/src/closure/dispatch/value_call.rs +++ b/crates/perry-runtime/src/closure/dispatch/value_call.rs @@ -78,7 +78,7 @@ pub unsafe extern "C" fn js_native_call_value( // probe can only match a bound native callable, which exists only once // `callable_exports` minted one (arming the table). if let Some(ops) = crate::object::nm_namespace_ops() { - if let Some(result) = unsafe { (ops.ee_dynamic_super)(func_value) } { + if let Some(result) = unsafe { (ops.ee_dynamic_super)(func_value, args_ptr, args_len) } { return result; } } diff --git a/crates/perry-runtime/src/dgram.rs b/crates/perry-runtime/src/dgram.rs index e4cbcb0f6e..17b151ee4d 100644 --- a/crates/perry-runtime/src/dgram.rs +++ b/crates/perry-runtime/src/dgram.rs @@ -109,6 +109,8 @@ pub(crate) const KEY_ABORT_LISTENER: &[u8] = b"__perryDgramAbortListener"; /// Reactor id for the live OS socket (real mode only); links a JS socket back /// to its `UdpSocket` + recv thread in [`crate::dgram_reactor`]. pub(crate) const KEY_REACTOR_ID: &[u8] = b"__perryDgramReactorId"; +pub(crate) const KEY_ASYNC_ID: &[u8] = b"__perryDgramAsyncId"; +pub(crate) const KEY_TRIGGER_ASYNC_ID: &[u8] = b"__perryDgramTriggerAsyncId"; type MethodThunk = extern "C" fn(*const ClosureHeader, f64) -> f64; @@ -493,7 +495,23 @@ pub(crate) fn socket_object(socket_type: &str) -> f64 { ); } } - socket + let scope = crate::gc::RuntimeHandleScope::new(); + let socket = scope.root_nanbox_f64(socket); + let ids = crate::async_hooks::init_resource("UDPWRAP", socket.get_nanbox_f64(), true); + set_hidden_value(socket.get_nanbox_f64(), KEY_ASYNC_ID, ids.async_id as f64); + set_hidden_value( + socket.get_nanbox_f64(), + KEY_TRIGGER_ASYNC_ID, + ids.trigger_async_id as f64, + ); + socket.get_nanbox_f64() +} + +pub(crate) fn socket_async_ids(socket: f64) -> crate::async_hooks::AsyncResourceIds { + crate::async_hooks::AsyncResourceIds { + async_id: get_hidden_value(socket, KEY_ASYNC_ID).unwrap_or(0.0) as u64, + trigger_async_id: get_hidden_value(socket, KEY_TRIGGER_ASYNC_ID).unwrap_or(0.0) as u64, + } } extern "C" fn dgram_async_dispose(closure: *const ClosureHeader) -> f64 { diff --git a/crates/perry-runtime/src/dgram/listeners.rs b/crates/perry-runtime/src/dgram/listeners.rs index ae9d6ec64b..940b339854 100644 --- a/crates/perry-runtime/src/dgram/listeners.rs +++ b/crates/perry-runtime/src/dgram/listeners.rs @@ -214,12 +214,15 @@ pub(crate) fn emit_event_value(socket: f64, event: f64, args: &[f64]) -> bool { if snapshot.is_empty() { return false; } + let async_ids = socket_async_ids(socket); + crate::async_hooks::enter_resource_scope(async_ids); if snapshot.iter().any(|(_, once)| *once) { remove_once_listeners(socket, event); } for (listener, _) in snapshot { call_function(listener, socket, args); } + crate::async_hooks::leave_resource_scope(async_ids.async_id); true } diff --git a/crates/perry-runtime/src/dgram/ops.rs b/crates/perry-runtime/src/dgram/ops.rs index 8ef380f9f4..813b995683 100644 --- a/crates/perry-runtime/src/dgram/ops.rs +++ b/crates/perry-runtime/src/dgram/ops.rs @@ -189,7 +189,9 @@ pub(crate) fn bind_impl(socket: f64, args: &[f64]) -> f64 { Ok(()) => { emit_event(socket, "listening", &[]); if let Some(callback) = callback_from_args(args) { - call_function(callback, socket, &[]); + crate::async_hooks::run_resource_scope(socket_async_ids(socket), || { + call_function(callback, socket, &[]); + }); } } Err(error) => { @@ -224,6 +226,7 @@ pub(crate) fn close_impl(socket: f64, args: &[f64]) -> f64 { if is_truthy_hidden(socket, KEY_CLOSED) { return undefined_value(); } + let async_ids = socket_async_ids(socket); if deterministic() { remove_bound_socket(socket); } else if let Some(id) = reactor_id(socket) { @@ -235,8 +238,12 @@ pub(crate) fn close_impl(socket: f64, args: &[f64]) -> f64 { set_hidden_value(socket, KEY_CLOSED, bool_value(true)); emit_event(socket, "close", &[]); if let Some(callback) = callback_from_args(args) { + // The UDPWRAP represents the socket lifetime. Node does not enter the + // socket's execution scope merely to invoke the optional close() + // completion callback (event listeners have their own dispatch path). call_function(callback, socket, &[]); } + crate::async_hooks::destroy(async_ids.async_id); socket } diff --git a/crates/perry-runtime/src/dns.rs b/crates/perry-runtime/src/dns.rs index 5453e9f7d5..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 { @@ -985,12 +987,10 @@ fn lookup_all_result(addresses: &[ResolvedAddress]) -> f64 { array_value_from_values(&values) } -fn queue_callback(callback_value: f64, args: &[f64]) { +fn queue_callback(callback_value: f64, args: &[f64], provider_type: &'static str) { let callback = closure_ptr_from_value(callback_value) .unwrap_or_else(|| throw_error_value(invalid_callback_error(callback_value))); - unsafe { - crate::builtins::js_queue_next_tick_args(callback as i64, args.as_ptr(), args.len() as i32); - } + crate::timer::schedule_native_callback(callback as i64, args, provider_type); } fn lookup_value(hostname: &str, options: LookupOptions) -> Result { @@ -1153,16 +1153,12 @@ fn promise_reverse_args(args: i64) -> String { fn call_success_callback(callback: f64, value: f64) { let args = [null_value(), value]; - unsafe { - crate::closure::js_native_call_value(callback, args.as_ptr(), args.len()); - } + queue_callback(callback, &args, "QUERYWRAP"); } fn call_error_callback(callback: f64, error: f64) { let args = [error]; - unsafe { - crate::closure::js_native_call_value(callback, args.as_ptr(), args.len()); - } + queue_callback(callback, &args, "QUERYWRAP"); } fn dns_callback_resolve(args: i64, default_kind: Option) -> f64 { @@ -1250,19 +1246,74 @@ 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()); + }); + }); } - obj + let (_, obj_ptr) = obj.across_mut::(|| { + obj.with_mut_ptr::(|obj_ptr| { + let _ = crate::async_hooks::init_resource( + "DNSCHANNEL", + js_nanbox_pointer(obj_ptr as i64), + true, + ); + }); + }); + obj_ptr } diff --git a/crates/perry-runtime/src/dns/ffi.rs b/crates/perry-runtime/src/dns/ffi.rs index 059fc60c1b..41311f4665 100644 --- a/crates/perry-runtime/src/dns/ffi.rs +++ b/crates/perry-runtime/src/dns/ffi.rs @@ -35,7 +35,11 @@ pub extern "C" fn js_dns_lookup(args: i64) -> f64 { Ok(values) => values, Err(error) => vec![error], }; - queue_callback(callback_handle.get_nanbox_f64(), &callback_args); + queue_callback( + callback_handle.get_nanbox_f64(), + &callback_args, + "GETADDRINFOREQWRAP", + ); undefined_value() } @@ -62,7 +66,7 @@ pub extern "C" fn js_dns_lookup_service(args: i64) -> f64 { Ok((hostname, service)) => vec![null_value(), str_value(&hostname), str_value(&service)], Err(error) => vec![error], }; - queue_callback(callback_value, &callback_args); + queue_callback(callback_value, &callback_args, "GETNAMEINFOREQWRAP"); undefined_value() } @@ -253,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/callbacks.rs b/crates/perry-runtime/src/fs/callbacks.rs index 2c226a2eda..89de75c6c5 100644 --- a/crates/perry-runtime/src/fs/callbacks.rs +++ b/crates/perry-runtime/src/fs/callbacks.rs @@ -7,7 +7,6 @@ use super::*; #[no_mangle] pub extern "C" fn js_fs_read_file_callback(path_value: f64, encoding: f64, callback: f64) -> f64 { - use crate::closure::js_closure_call2; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; @@ -15,7 +14,7 @@ pub extern "C" fn js_fs_read_file_callback(path_value: f64, encoding: f64, callb unsafe { if let Some(err_val) = fs_callback_read_error(path_value, "open") { if !cb_ptr.is_null() { - js_closure_call2(cb_ptr, err_val, f64::from_bits(TAG_UNDEFINED)); + defer_fs_callback_chain(cb_ptr, &[err_val, f64::from_bits(TAG_UNDEFINED)], 4); } return f64::from_bits(TAG_UNDEFINED); } @@ -39,7 +38,7 @@ pub extern "C" fn js_fs_read_file_callback(path_value: f64, encoding: f64, callb }; if !cb_ptr.is_null() { - js_closure_call2(cb_ptr, f64::from_bits(TAG_NULL), data_val); + defer_fs_callback_chain(cb_ptr, &[f64::from_bits(TAG_NULL), data_val], 4); } f64::from_bits(TAG_UNDEFINED) } @@ -131,54 +130,27 @@ fn catch_callback_throw(call: impl FnOnce() -> f64) -> Result { } } -/// Trampoline body for a deferred single-arg fs completion callback. Invoked -/// with no JS args from the microtask drain (`js_closure_call0`); the real -/// callback and its single argument travel as tag-aware capture slots so the -/// GC keeps them live — and rewrites their addresses — across any collection -/// that lands between the enqueue and the drain. Slot 0 is the callback -/// NaN-boxed as a POINTER value, slot 1 the argument (an error value or `null`). -extern "C" fn deferred_fs_cb1_impl(closure: *const ClosureHeader) -> f64 { - const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - let cb = extract_closure_ptr(crate::closure::js_closure_get_capture_f64(closure, 0)); - let arg0 = crate::closure::js_closure_get_capture_f64(closure, 1); - if !cb.is_null() { - crate::closure::js_closure_call1(cb, arg0); +/// Deliver an fs completion on a later event-loop turn under a real +/// `FSREQCALLBACK` async resource. The timer queue roots both the callback and +/// values and brackets invocation with init/before/after/destroy, so every +/// callback-style fs API gets Node-compatible timing, hook ancestry, resource +/// identity, and AsyncLocalStorage propagation through one path. +fn defer_fs_callback(callback: *const ClosureHeader, args: &[f64]) { + if callback.is_null() { + return; } - f64::from_bits(TAG_UNDEFINED) + crate::timer::schedule_native_callback(callback as i64, args, "FSREQCALLBACK"); } -/// Deliver a void fs op's completion callback on a LATER tick instead of -/// synchronously (#6401). Node's async `fs.*` functions dispatch to the libuv -/// threadpool and never invoke the callback in the same turn they were called; -/// firing it inline reorders execution — code that runs `main()` from an fs -/// callback would then observe module-top-level `const`s that Node has already -/// initialized as still-uninitialized. We still run the syscall eagerly (as -/// before) and only defer the `(err)` / `(null)` delivery, mirroring the -/// already-deferred `fs.opendir`/`Dir.read` path (`dir_schedule_read_callback`). -fn defer_fs_cb1(callback: *const ClosureHeader, arg0: f64) { +fn defer_fs_callback_chain(callback: *const ClosureHeader, args: &[f64], count: usize) { if callback.is_null() { return; } - // Register the trampoline's arity (0 JS params) before it can first be - // dispatched, so `resolve_strategy` doesn't cache a stale strategy (#6475). - thread_local! { - static ARITY_REGISTERED: std::cell::Cell = const { std::cell::Cell::new(false) }; - } - ARITY_REGISTERED.with(|r| { - if !r.get() { - crate::closure::js_register_closure_arity(deferred_fs_cb1_impl as *const u8, 0); - r.set(true); - } - }); - let cb_boxed = crate::value::js_nanbox_pointer(callback as i64); - let closure = crate::closure::js_closure_alloc(deferred_fs_cb1_impl as *const u8, 2); - crate::closure::js_closure_set_capture_f64(closure, 0, cb_boxed); - crate::closure::js_closure_set_capture_f64(closure, 1, arg0); - crate::builtins::js_queue_microtask(closure as i64); + crate::timer::schedule_native_callback_chain(callback as i64, args, "FSREQCALLBACK", count); } pub(crate) fn call_cb0(callback: *const ClosureHeader) { - defer_fs_cb1(callback, f64::from_bits(0x7FFC_0000_0000_0002)); + defer_fs_callback(callback, &[f64::from_bits(0x7FFC_0000_0000_0002)]); } /// Invoke a 2-arg callback with (err, undefined). Used by read-style ops @@ -186,14 +158,14 @@ pub(crate) fn call_cb0(callback: *const ClosureHeader) { pub(crate) unsafe fn call_cb_err2(callback: *const ClosureHeader, err_val: f64) { const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; if !callback.is_null() { - crate::closure::js_closure_call2(callback, err_val, f64::from_bits(TAG_UNDEFINED)); + defer_fs_callback(callback, &[err_val, f64::from_bits(TAG_UNDEFINED)]); } } /// Invoke a 1-arg callback with (err). Used by void ops (mkdir/unlink/rm/…) /// when the pre-flight probe detected an io::Error. pub(crate) unsafe fn call_cb_err1(callback: *const ClosureHeader, err_val: f64) { - defer_fs_cb1(callback, err_val); + defer_fs_callback(callback, &[err_val]); } /// `fs.writeFile(path, data, callback)` — sync write + immediate callback. @@ -346,7 +318,7 @@ pub extern "C" fn js_fs_exists_callback(path_value: f64, callback: f64) -> f64 { let cb = required_callback(callback); if !cb.is_null() { let arg = if exists { TAG_TRUE } else { TAG_FALSE }; - crate::closure::js_closure_call1(cb, f64::from_bits(arg)); + defer_fs_callback(cb, &[f64::from_bits(arg)]); } f64::from_bits(TAG_UNDEFINED) } @@ -378,7 +350,7 @@ pub extern "C" fn js_fs_readdir_callback(path_value: f64, arg1: f64, arg2: f64) let entries = f64::from_bits(crate::value::JSValue::pointer(entries.to_bits() as *const u8).bits()); if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), entries); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), entries]); } f64::from_bits(TAG_UNDEFINED) } @@ -402,7 +374,7 @@ pub extern "C" fn js_fs_stat_callback(path_value: f64, arg1: f64, arg2: f64) -> } let stats = js_fs_stat_sync_options(path_value, options); if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), stats); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), stats]); } f64::from_bits(TAG_UNDEFINED) } @@ -426,7 +398,7 @@ pub extern "C" fn js_fs_lstat_callback(path_value: f64, arg1: f64, arg2: f64) -> } let stats = js_fs_lstat_sync_options(path_value, options); if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), stats); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), stats]); } f64::from_bits(TAG_UNDEFINED) } @@ -450,7 +422,7 @@ pub extern "C" fn js_fs_statfs_callback(path_value: f64, arg1: f64, arg2: f64) - } let stats = js_fs_statfs_sync_options(path_value, options); if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), stats); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), stats]); } f64::from_bits(TAG_UNDEFINED) } @@ -469,7 +441,7 @@ pub extern "C" fn js_fs_opendir_callback(path_value: f64, arg1: f64, arg2: f64) } }; if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), dir); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), dir]); } f64::from_bits(TAG_UNDEFINED) } @@ -491,7 +463,7 @@ pub extern "C" fn js_fs_glob_callback(pattern_value: f64, arg1: f64, arg2: f64) }) { Ok(entries) => { if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), entries); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), entries]); } } Err(err) => unsafe { call_cb_err2(cb, err) }, @@ -517,7 +489,7 @@ pub extern "C" fn js_fs_fstat_callback(fd_value: f64, arg1: f64, arg2: f64) -> f if !cb.is_null() { match result { Ok(stats) => { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), stats); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), stats]); } Err(err) => unsafe { call_cb_err2(cb, err) }, } @@ -679,7 +651,7 @@ pub extern "C" fn js_fs_readlink_callback(path_value: f64, arg1: f64, arg2: f64) } }; if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), value]); } f64::from_bits(TAG_UNDEFINED) } @@ -703,7 +675,7 @@ pub extern "C" fn js_fs_realpath_callback(path_value: f64, arg1: f64, arg2: f64) } }; if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), value]); } f64::from_bits(TAG_UNDEFINED) } @@ -737,7 +709,7 @@ pub extern "C" fn js_fs_mkdtemp_callback(prefix_value: f64, arg1: f64, arg2: f64 }, }; if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), value]); } f64::from_bits(TAG_UNDEFINED) } @@ -771,7 +743,7 @@ pub extern "C" fn js_fs_open_callback(path_value: f64, arg1: f64, arg2: f64, arg }, }; if !cb.is_null() { - crate::closure::js_closure_call2(cb, f64::from_bits(TAG_NULL), fd); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), fd]); } f64::from_bits(TAG_UNDEFINED) } @@ -1024,7 +996,7 @@ pub extern "C" fn js_fs_read_callback( crate::fs::validate::validate_fd(fd_value); let cb = required_callback(callback); if let Some(err_val) = crate::fs::validate::fd_open_callback_error(fd_value, "read") { - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } let bytes = match crate::fs::read_sync_result( @@ -1038,12 +1010,12 @@ pub extern "C" fn js_fs_read_callback( // The callback form reports the syscall error, it does not throw. Err(err) => { let err_val = unsafe { build_fs_error_value_no_path(&err, "read") }; - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } }; if !cb.is_null() { - crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffer_value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), bytes, buffer_value]); } f64::from_bits(TAG_UNDEFINED) } @@ -1061,7 +1033,7 @@ pub extern "C" fn js_fs_read_callback_options( crate::fs::validate::validate_fd(fd_value); let cb = required_callback(callback); if let Some(err_val) = crate::fs::validate::fd_open_callback_error(fd_value, "read") { - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } let buffer_len = buffer_len_from_value(buffer_value) as f64; @@ -1076,12 +1048,12 @@ pub extern "C" fn js_fs_read_callback_options( // The callback form reports the syscall error, it does not throw. Err(err) => { let err_val = unsafe { build_fs_error_value_no_path(&err, "read") }; - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } }; if !cb.is_null() { - crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffer_value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), bytes, buffer_value]); } f64::from_bits(TAG_UNDEFINED) } @@ -1094,7 +1066,7 @@ pub extern "C" fn js_fs_write_callback(fd_value: f64, data_value: f64, callback: crate::fs::validate::validate_fd(fd_value); let cb = required_callback(callback); if let Some(err_val) = crate::fs::validate::fd_open_callback_error(fd_value, "write") { - crate::closure::js_closure_call3(cb, err_val, 0.0, data_value); + defer_fs_callback(cb, &[err_val, 0.0, data_value]); return f64::from_bits(TAG_UNDEFINED); } let bytes = match crate::fs::write_string_sync_result( @@ -1105,12 +1077,12 @@ pub extern "C" fn js_fs_write_callback(fd_value: f64, data_value: f64, callback: Ok(bytes) => bytes, Err(err) => { let err_val = unsafe { build_fs_error_value_no_path(&err, "write") }; - crate::closure::js_closure_call3(cb, err_val, 0.0, data_value); + defer_fs_callback(cb, &[err_val, 0.0, data_value]); return f64::from_bits(TAG_UNDEFINED); } }; if !cb.is_null() { - crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, data_value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), bytes, data_value]); } f64::from_bits(TAG_UNDEFINED) } @@ -1128,7 +1100,7 @@ pub extern "C" fn js_fs_write_buffer_callback_options( crate::fs::validate::validate_fd(fd_value); let cb = required_callback(callback); if let Some(err_val) = crate::fs::validate::fd_open_callback_error(fd_value, "write") { - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } let buffer_len = buffer_len_from_value(buffer_value) as f64; @@ -1147,12 +1119,12 @@ pub extern "C" fn js_fs_write_buffer_callback_options( Ok(bytes) => bytes, Err(err) => { let err_val = unsafe { build_fs_error_value_no_path(&err, "write") }; - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } }; if !cb.is_null() { - crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffer_value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), bytes, buffer_value]); } f64::from_bits(TAG_UNDEFINED) } @@ -1172,7 +1144,7 @@ pub extern "C" fn js_fs_write_buffer_callback( crate::fs::validate::validate_fd(fd_value); let cb = required_callback(callback); if let Some(err_val) = crate::fs::validate::fd_open_callback_error(fd_value, "write") { - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } let bytes = match crate::fs::write_buffer_sync_result( @@ -1185,12 +1157,12 @@ pub extern "C" fn js_fs_write_buffer_callback( Ok(bytes) => bytes, Err(err) => { let err_val = unsafe { build_fs_error_value_no_path(&err, "write") }; - crate::closure::js_closure_call3(cb, err_val, 0.0, buffer_value); + defer_fs_callback(cb, &[err_val, 0.0, buffer_value]); return f64::from_bits(TAG_UNDEFINED); } }; if !cb.is_null() { - crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffer_value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), bytes, buffer_value]); } f64::from_bits(TAG_UNDEFINED) } @@ -1208,12 +1180,12 @@ pub extern "C" fn js_fs_readv_callback( crate::fs::validate::validate_fd(fd_value); let cb = required_callback(callback); if let Some(err_val) = crate::fs::validate::fd_open_callback_error(fd_value, "read") { - crate::closure::js_closure_call3(cb, err_val, 0.0, buffers_value); + defer_fs_callback(cb, &[err_val, 0.0, buffers_value]); return f64::from_bits(TAG_UNDEFINED); } let bytes = js_fs_readv_sync(fd_value, buffers_value, position_value); if !cb.is_null() { - crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffers_value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), bytes, buffers_value]); } f64::from_bits(TAG_UNDEFINED) } @@ -1231,12 +1203,12 @@ pub extern "C" fn js_fs_writev_callback( crate::fs::validate::validate_fd(fd_value); let cb = required_callback(callback); if let Some(err_val) = crate::fs::validate::fd_open_callback_error(fd_value, "write") { - crate::closure::js_closure_call3(cb, err_val, 0.0, buffers_value); + defer_fs_callback(cb, &[err_val, 0.0, buffers_value]); return f64::from_bits(TAG_UNDEFINED); } let bytes = js_fs_writev_sync(fd_value, buffers_value, position_value); if !cb.is_null() { - crate::closure::js_closure_call3(cb, f64::from_bits(TAG_NULL), bytes, buffers_value); + defer_fs_callback(cb, &[f64::from_bits(TAG_NULL), bytes, buffers_value]); } f64::from_bits(TAG_UNDEFINED) } 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 63abfd9668..1848fc5308 100644 --- a/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs +++ b/crates/perry-runtime/src/fs/dir_glob_watch/watch.rs @@ -60,6 +60,7 @@ struct FsWatchState { listeners: HashMap>, signal: f64, abort_listener: f64, + async_ids: crate::async_hooks::AsyncResourceIds, } #[derive(Clone, PartialEq)] @@ -82,6 +83,7 @@ struct WatchFileState { path: String, object_value: f64, timer_id: i64, + async_id: u64, bigint: bool, previous: Option, listeners: HashMap>, @@ -589,6 +591,7 @@ fn close_fs_watcher(id: usize) { return; }; crate::timer::clearInterval(state.timer_id); + crate::async_hooks::destroy(state.async_ids.async_id); remove_abort_listener(state.signal, state.abort_listener); let close_listeners = take_event_listeners(&mut state.listeners, "close"); for listener in close_listeners { @@ -600,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); }); @@ -1461,6 +1468,7 @@ pub extern "C" fn js_fs_watch(path_value: f64, arg1: f64, arg2: f64) -> f64 { }; let id = next_watch_id(); let object_value = build_fs_watcher_object(id); + let async_ids = crate::async_hooks::init_resource("FSEVENTWRAP", object_value, true); let timer_callback = poll_closure_value(fs_watcher_poll_impl as *const u8, id); let timer_id = crate::timer::setInterval(timer_callback as i64, FS_WATCH_POLL_INTERVAL_MS); if !persistent { @@ -1487,6 +1495,7 @@ pub extern "C" fn js_fs_watch(path_value: f64, arg1: f64, arg2: f64) -> f64 { listeners, signal: signal_value, abort_listener, + async_ids, }, ); }); @@ -1526,6 +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 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") }; @@ -1543,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, @@ -1683,8 +1694,12 @@ pub(crate) fn scan_fs_watcher_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi } pub(crate) fn promise_value_fs(value: f64) -> f64 { - let promise = crate::promise::js_promise_resolved(value); - f64::from_bits(crate::value::JSValue::pointer(promise as *const u8).bits()) + let scope = crate::gc::RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(value); + crate::async_hooks::run_provider_completion("FSREQPROMISE", || { + let promise = crate::promise::js_promise_resolved(value.get_nanbox_f64()); + f64::from_bits(crate::value::JSValue::pointer(promise as *const u8).bits()) + }) } pub(crate) fn promise_undefined_fs() -> f64 { @@ -1692,7 +1707,11 @@ pub(crate) fn promise_undefined_fs() -> f64 { } pub(crate) fn promise_rejected_fs(reason: f64) -> f64 { - let promise = crate::promise::js_promise_new(); - crate::promise::js_promise_reject(promise, reason); - f64::from_bits(crate::value::JSValue::pointer(promise as *const u8).bits()) + let scope = crate::gc::RuntimeHandleScope::new(); + let reason = scope.root_nanbox_f64(reason); + crate::async_hooks::run_provider_completion("FSREQPROMISE", || { + let promise = crate::promise::js_promise_new(); + crate::promise::js_promise_reject(promise, reason.get_nanbox_f64()); + f64::from_bits(crate::value::JSValue::pointer(promise as *const u8).bits()) + }) } diff --git a/crates/perry-runtime/src/fs/filehandle.rs b/crates/perry-runtime/src/fs/filehandle.rs index bd0a81d858..be05274c27 100644 --- a/crates/perry-runtime/src/fs/filehandle.rs +++ b/crates/perry-runtime/src/fs/filehandle.rs @@ -1050,14 +1050,22 @@ pub(crate) extern "C" fn filehandle_writer_impl( self_value } +fn filehandle_close_promise() -> f64 { + crate::async_hooks::run_provider_completion("FILEHANDLECLOSEREQ", || { + let promise = + crate::promise::js_promise_resolved(f64::from_bits(crate::value::TAG_UNDEFINED)); + f64::from_bits(crate::value::JSValue::pointer(promise as *const u8).bits()) + }) +} + pub(crate) extern "C" fn filehandle_close_impl(closure: *const ClosureHeader) -> f64 { let fd = filehandle_fd(closure); if let Some(handle) = filehandle_object(closure) { close_filehandle_fd(filehandle_field_fd(handle).unwrap_or(fd), handle); - return promise_undefined_fs(); + return filehandle_close_promise(); } let _ = js_fs_close_sync(fd as f64); - promise_undefined_fs() + filehandle_close_promise() } pub(crate) extern "C" fn filehandle_sync_impl(closure: *const ClosureHeader) -> f64 { 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/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 5f9be0112f..bef4506c5c 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -1386,6 +1386,26 @@ impl<'a> RuntimeRootVisitor<'a> { } } + /// Visit a metadata-only NaN-boxed heap pointer. + /// + /// Unlike `visit_nanbox_f64_slot`, this repairs an address only during a + /// rewrite pass and never marks the referent. It is used by weak identity + /// side tables whose entries are pruned by the referent's finalizer. + pub fn visit_metadata_nanbox_f64_slot(&mut self, slot: &mut f64) -> bool { + let bits = slot.to_bits(); + let tag = bits & TAG_MASK; + if !matches!(tag, POINTER_TAG | STRING_TAG | BIGINT_TAG) { + return false; + } + let addr = (bits & POINTER_MASK) as usize; + if let Some(new_addr) = self.visit_metadata_raw_addr(addr) { + *slot = f64::from_bits(tag | (new_addr as u64 & POINTER_MASK)); + true + } else { + false + } + } + /// Visit a raw metadata-only `usize` slot address. /// /// # Safety diff --git a/crates/perry-runtime/src/module_require.rs b/crates/perry-runtime/src/module_require.rs index eab17d5baf..97a31561ee 100644 --- a/crates/perry-runtime/src/module_require.rs +++ b/crates/perry-runtime/src/module_require.rs @@ -1213,6 +1213,11 @@ fn dynamic_import_fallback_promise(spec: f64, deferred_note: Option) -> let promise = crate::promise::js_promise_resolved(ns_handle.get_nanbox_f64()); return js_nanbox_pointer(promise as i64); } + #[cfg(feature = "dyn-eval")] + if let Some(namespace) = dynamic_import_javascript_data_url(&spec_str) { + let promise = crate::promise::js_promise_resolved(namespace); + return js_nanbox_pointer(promise as i64); + } let message = deferred_note.unwrap_or_else(|| format!("Cannot find module '{spec_str}'")); let msg_ptr = js_string_from_bytes(message.as_ptr(), message.len() as u32); crate::node_submodules::register_error_code_pub(msg_ptr, "ERR_MODULE_NOT_FOUND"); @@ -1223,6 +1228,54 @@ fn dynamic_import_fallback_promise(spec: f64, deferred_note: Option) -> js_nanbox_pointer(promise as i64) } +#[cfg(feature = "dyn-eval")] +fn dynamic_import_javascript_data_url(specifier: &str) -> Option { + let encoded = specifier.strip_prefix("data:text/javascript,")?; + let mut decoded = Vec::with_capacity(encoded.len()); + let bytes = encoded.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hi = (bytes[index + 1] as char).to_digit(16)?; + let lo = (bytes[index + 2] as char).to_digit(16)?; + decoded.push(((hi << 4) | lo) as u8); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + let source = std::str::from_utf8(&decoded).ok()?.trim(); + let declaration = source.strip_prefix("export const ")?; + let (name, expression) = declaration.split_once('=')?; + let name = name.trim(); + if name.is_empty() + || !name.chars().enumerate().all(|(index, ch)| { + ch == '_' + || ch == '$' + || (index == 0 && ch.is_ascii_alphabetic()) + || (index > 0 && ch.is_ascii_alphanumeric()) + }) + { + return None; + } + let expression = expression + .trim() + .strip_suffix(';') + .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 = 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 /// arms (#6660). Returns a NaN-boxed promise; never throws synchronously /// (`import()` always rejects, per spec). diff --git a/crates/perry-runtime/src/node_stream.rs b/crates/perry-runtime/src/node_stream.rs index df673b81a8..93ee525797 100644 --- a/crates/perry-runtime/src/node_stream.rs +++ b/crates/perry-runtime/src/node_stream.rs @@ -99,6 +99,7 @@ const STREAM_END_EMITTED_KEY: &[u8] = b"__perryStreamEndEmitted"; const STREAM_ENDED_KEY: &[u8] = b"__perryStreamEnded"; const STREAM_MAX_LISTENERS_KEY: &[u8] = b"__perryStreamMaxListeners"; const STREAM_CAPTURE_REJECTIONS_KEY: &[u8] = b"__perryStreamCaptureRejections"; +const EVENT_EMITTER_ASYNC_RESOURCE_KEY: &[u8] = b"__perryEventEmitterAsyncResource"; const WRITABLE_WRITE_KEY: &[u8] = b"__perryWritableWrite"; const WRITABLE_FINISH_SCHEDULED_KEY: &[u8] = b"__perryWritableFinishScheduled"; const WRITABLE_FINISH_EMITTED_KEY: &[u8] = b"__perryWritableFinishEmitted"; diff --git a/crates/perry-runtime/src/node_stream_constructors.rs b/crates/perry-runtime/src/node_stream_constructors.rs index f6101e1901..5476ca631f 100644 --- a/crates/perry-runtime/src/node_stream_constructors.rs +++ b/crates/perry-runtime/src/node_stream_constructors.rs @@ -360,12 +360,13 @@ mod pipeline; mod web_adapter; pub use builders::{ - js_array_subclass_init, js_event_emitter_subclass_init, js_node_stream_duplex_new, - js_node_stream_duplex_subclass_init, js_node_stream_passthrough_new, - js_node_stream_readable_from, js_node_stream_readable_from_options, - js_node_stream_readable_new, js_node_stream_readable_subclass_init, - js_node_stream_transform_new, js_node_stream_transform_subclass_init, - js_node_stream_writable_new, js_node_stream_writable_subclass_init, + js_array_subclass_init, js_event_emitter_async_resource_subclass_init, + js_event_emitter_subclass_init, js_node_stream_duplex_new, js_node_stream_duplex_subclass_init, + js_node_stream_passthrough_new, js_node_stream_readable_from, + js_node_stream_readable_from_options, js_node_stream_readable_new, + js_node_stream_readable_subclass_init, js_node_stream_transform_new, + js_node_stream_transform_subclass_init, js_node_stream_writable_new, + js_node_stream_writable_subclass_init, }; pub use introspection::{ diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index 23797f42b4..aee7a3b187 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -94,6 +94,71 @@ pub extern "C" fn js_event_emitter_subclass_init(this: f64) -> f64 { this } +/// Initialize a source-compiled subclass of EventEmitterAsyncResource on its +/// already-allocated `this` object. The listener surface remains the generic +/// object-backed EventEmitter implementation; a hidden AsyncResource supplies +/// the execution scope, lifecycle, ids, and `asyncResource.eventEmitter` +/// back-reference. +#[no_mangle] +pub extern "C" fn js_event_emitter_async_resource_subclass_init(this: f64, options: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let this_handle = scope.root_nanbox_f64(this); + let options_handle = scope.root_nanbox_f64(options); + js_event_emitter_subclass_init(this_handle.get_nanbox_f64()); + + let this = this_handle.get_nanbox_f64(); + let raw = raw_ptr_from_value(this); + if raw == 0 || unsafe { gc_type_for_ptr(raw) } != Some(crate::gc::GC_TYPE_OBJECT) { + return this; + } + let options = options_handle.get_nanbox_f64(); + let options_value = JSValue::from_bits(options.to_bits()); + let mut name = if options_value.is_any_string() { + options + } else { + let key = crate::string::js_string_from_bytes(b"name".as_ptr(), 4); + let options = options_handle.get_nanbox_f64(); + let options_obj = raw_ptr_from_value(options) as *const ObjectHeader; + if options_obj.is_null() { + f64::from_bits(crate::value::TAG_UNDEFINED) + } else { + crate::object::js_object_get_field_by_name_f64(options_obj, key) + } + }; + if JSValue::from_bits(name.to_bits()).is_undefined() { + let current_obj = raw_ptr_from_value(this_handle.get_nanbox_f64()) as *mut ObjectHeader; + let class_id = unsafe { (*current_obj).class_id }; + let default_name = crate::object::class_name_for_id(class_id) + .unwrap_or_else(|| "EventEmitterAsyncResource".to_string()); + let name_ptr = + 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_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); + unsafe { + crate::object::js_object_set_field_by_name( + raw as *mut ObjectHeader, + hidden_key(EVENT_EMITTER_ASYNC_RESOURCE_KEY), + f64::from_bits(crate::value::js_nanbox_pointer(resource).to_bits()), + ); + install_event_emitter_async_resource_instance_methods( + raw as *mut ObjectHeader, + this_handle.get_nanbox_f64(), + ); + } + this_handle.get_nanbox_f64() +} + /// `super(n)` for a source-compiled `class X extends Array` (e.g. lru-cache's /// `ZeroArray`: `class ZeroArray extends Array { constructor(n){ super(n); /// this.fill(0) } }`). Perry models the subclass instance as a plain object, diff --git a/crates/perry-runtime/src/node_stream_constructors/pipeline.rs b/crates/perry-runtime/src/node_stream_constructors/pipeline.rs index 893064dc8a..27ab7fa395 100644 --- a/crates/perry-runtime/src/node_stream_constructors/pipeline.rs +++ b/crates/perry-runtime/src/node_stream_constructors/pipeline.rs @@ -221,15 +221,16 @@ pub extern "C" fn js_node_stream_finished(args: *const crate::array::ArrayHeader .is_some_and(|v| v.to_bits() == TAG_FALSE); if watch_close || watch_finish { add_finished_once_listeners(stream, callback, watch_finish, watch_close); + } else { + // The default callback form observes normal stream completion too. + // A shared once-guard makes the first terminal event sufficient for + // this lightweight stream model and prevents the duplex sequence + // (`finish`, `end`, `close`) from invoking the callback repeatedly. + add_finished_cleanup_completion_listener(stream, callback); } 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 d846a52dd5..150ebb834e 100644 --- a/crates/perry-runtime/src/node_stream_dispatch.rs +++ b/crates/perry-runtime/src/node_stream_dispatch.rs @@ -257,6 +257,259 @@ pub(crate) fn install_event_emitter_prototype_methods(proto: *mut ObjectHeader) } } +enum EventEmitterAsyncResourceBacking { + ExternalEmitter(i64), + RuntimeResource(i64), +} + +fn event_emitter_async_resource_backing(receiver: f64) -> Option { + 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() + .is_some_and(|probe| unsafe { probe(handle) }) + { + return Some(EventEmitterAsyncResourceBacking::ExternalEmitter(handle)); + } + let raw = handle as usize; + if crate::value::addr_class::is_plausible_heap_addr(raw) { + let key = scope.root_string_ptr(hidden_key(EVENT_EMITTER_ASYNC_RESOURCE_KEY)); + let raw = (receiver.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as usize; + let value = key.with_const_ptr::(|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) { + return Some(EventEmitterAsyncResourceBacking::RuntimeResource(resource)); + } + } + } + } + None +} + +fn require_event_emitter_async_resource_receiver( + closure: *const ClosureHeader, +) -> EventEmitterAsyncResourceBacking { + if let Some(backing) = event_emitter_async_resource_backing(this_value(closure)) { + return backing; + } + crate::node_submodules::diagnostics::throw_type_error_no_code( + b"Cannot read private member from an object whose class did not declare it", + ) +} + +extern "C" fn ns_ee_async_resource_emit_rest( + closure: *const ClosureHeader, + event: f64, + rest: f64, +) -> f64 { + let backing = require_event_emitter_async_resource_receiver(closure); + let runtime_async_id = match backing { + EventEmitterAsyncResourceBacking::RuntimeResource(resource) => { + crate::async_hooks::js_async_resource_async_id(resource) as u64 + } + EventEmitterAsyncResourceBacking::ExternalEmitter(_) => 0, + }; + if runtime_async_id != 0 { + crate::async_hooks::js_async_hooks_provider_enter(runtime_async_id); + } + let result = ns_emit_rest(closure, event, rest); + if runtime_async_id != 0 { + crate::async_hooks::js_async_hooks_provider_leave(runtime_async_id); + } + result +} + +extern "C" fn ns_ee_async_resource_destroy(closure: *const ClosureHeader) -> f64 { + match require_event_emitter_async_resource_receiver(closure) { + EventEmitterAsyncResourceBacking::ExternalEmitter(handle) => { + crate::object::event_emitter_async_resource_dispatch() + .map(|dispatch| unsafe { dispatch(handle, 3) }) + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)) + } + EventEmitterAsyncResourceBacking::RuntimeResource(resource) => { + crate::async_hooks::js_async_resource_emit_destroy(resource) as f64 + } + } +} + +extern "C" fn ns_ee_async_resource_getter(closure: *const ClosureHeader) -> f64 { + let operation = crate::closure::js_closure_get_capture_ptr(closure, 1) as u32; + match require_event_emitter_async_resource_receiver(closure) { + EventEmitterAsyncResourceBacking::ExternalEmitter(handle) => { + crate::object::event_emitter_async_resource_dispatch() + .map(|dispatch| unsafe { dispatch(handle, operation) }) + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)) + } + EventEmitterAsyncResourceBacking::RuntimeResource(resource) => match operation { + 0 => crate::async_hooks::js_async_resource_async_id(resource), + 1 => crate::async_hooks::js_async_resource_trigger_async_id(resource), + 2 => f64::from_bits(crate::value::js_nanbox_pointer(resource).to_bits()), + _ => f64::from_bits(crate::value::TAG_UNDEFINED), + }, + } +} + +/// Complete the `EventEmitterAsyncResource.prototype` surface. Its inherited +/// 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); + + 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); + 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); + + for (name, operation) in [ + ("asyncId", 0_i64), + ("triggerAsyncId", 1), + ("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, crate::value::TAG_UNDEFINED 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())); + 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), + ); + }); + }); + } +} + +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); + for (name, function) in [ + ("emit", ns_ee_async_resource_emit_rest as *const u8), + ("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.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 + // external emitter handles. Bind the resource accessors directly to the + // instance just like emit/emitDestroy; relying only on the native parent + // prototype leaves dynamic loop/property reads as `undefined`. + for (name, operation) in [ + ("asyncId", 0_i64), + ("triggerAsyncId", 1), + ("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.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), + ); + }); + }); + } +} + +#[no_mangle] +pub extern "C" fn js_event_emitter_async_resource_subclass_backing(receiver: i64) -> i64 { + let receiver = + f64::from_bits(crate::value::POINTER_TAG | (receiver as u64 & crate::value::POINTER_MASK)); + match event_emitter_async_resource_backing(receiver) { + Some(EventEmitterAsyncResourceBacking::RuntimeResource(resource)) => resource, + _ => 0, + } +} + pub(super) fn register_stub_arities() { let register = |func: *const u8, arity: u32| { crate::closure::js_register_closure_arity(func, arity); diff --git a/crates/perry-runtime/src/node_submodules/blob.rs b/crates/perry-runtime/src/node_submodules/blob.rs index feb8ca5e0f..1558b90783 100644 --- a/crates/perry-runtime/src/node_submodules/blob.rs +++ b/crates/perry-runtime/src/node_submodules/blob.rs @@ -72,19 +72,35 @@ thread_local! { static NEXT_FILE_BLOB_STREAM_ID: RefCell = const { RefCell::new(1) }; } +fn blob_reader_promise_value(value: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(value); + crate::async_hooks::run_provider_completion("BLOBREADER", || { + promise_value(value.get_nanbox_f64()) + }) +} + +fn blob_reader_promise_rejected(reason: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let reason = scope.root_nanbox_f64(reason); + crate::async_hooks::run_provider_completion("BLOBREADER", || { + promise_rejected(reason.get_nanbox_f64()) + }) +} + extern "C" fn blob_text_method(closure: *const ClosureHeader) -> f64 { let bytes = captured_blob_bytes(closure); - promise_value(bytes_to_text_value(&bytes)) + blob_reader_promise_value(bytes_to_text_value(&bytes)) } extern "C" fn blob_array_buffer_method(closure: *const ClosureHeader) -> f64 { let bytes = captured_blob_bytes(closure); - promise_value(bytes_to_array_buffer_value(&bytes)) + blob_reader_promise_value(bytes_to_array_buffer_value(&bytes)) } extern "C" fn blob_bytes_method(closure: *const ClosureHeader) -> f64 { let bytes = captured_blob_bytes(closure); - promise_value(bytes_to_uint8_array_value(&bytes)) + blob_reader_promise_value(bytes_to_uint8_array_value(&bytes)) } extern "C" fn blob_slice_method( @@ -120,22 +136,22 @@ extern "C" fn blob_stream_method(closure: *const ClosureHeader) -> f64 { extern "C" fn file_blob_text_method(closure: *const ClosureHeader) -> f64 { match read_file_blob_bytes(captured_file_blob_id(closure)) { - Ok(bytes) => promise_value(bytes_to_text_value(&bytes)), - Err(reason) => promise_rejected(reason), + Ok(bytes) => blob_reader_promise_value(bytes_to_text_value(&bytes)), + Err(reason) => blob_reader_promise_rejected(reason), } } extern "C" fn file_blob_array_buffer_method(closure: *const ClosureHeader) -> f64 { match read_file_blob_bytes(captured_file_blob_id(closure)) { - Ok(bytes) => promise_value(bytes_to_array_buffer_value(&bytes)), - Err(reason) => promise_rejected(reason), + Ok(bytes) => blob_reader_promise_value(bytes_to_array_buffer_value(&bytes)), + Err(reason) => blob_reader_promise_rejected(reason), } } extern "C" fn file_blob_bytes_method(closure: *const ClosureHeader) -> f64 { match read_file_blob_bytes(captured_file_blob_id(closure)) { - Ok(bytes) => promise_value(bytes_to_uint8_array_value(&bytes)), - Err(reason) => promise_rejected(reason), + Ok(bytes) => blob_reader_promise_value(bytes_to_uint8_array_value(&bytes)), + Err(reason) => blob_reader_promise_rejected(reason), } } diff --git a/crates/perry-runtime/src/node_submodules/fs_promises.rs b/crates/perry-runtime/src/node_submodules/fs_promises.rs index 17ccde3feb..5b1a2251a7 100644 --- a/crates/perry-runtime/src/node_submodules/fs_promises.rs +++ b/crates/perry-runtime/src/node_submodules/fs_promises.rs @@ -51,8 +51,8 @@ fn catch_fs_promises_throw(call: impl FnOnce() -> f64) -> Result { fn promise_from_sync_value(call: impl FnOnce() -> f64) -> f64 { match catch_fs_promises_throw(call) { - Ok(value) => promise_value(value), - Err(err) => promise_rejected(err), + Ok(value) => crate::fs::promise_value_fs(value), + Err(err) => crate::fs::promise_rejected_fs(err), } } @@ -61,28 +61,28 @@ fn promise_from_sync_undefined(call: impl FnOnce()) -> f64 { call(); f64::from_bits(crate::value::TAG_UNDEFINED) }) { - Ok(_) => promise_undefined(), - Err(err) => promise_rejected(err), + Ok(_) => crate::fs::promise_undefined_fs(), + Err(err) => crate::fs::promise_rejected_fs(err), } } fn promise_from_result_undefined(call: impl FnOnce() -> Result<(), f64>) -> f64 { match catch_fs_promises_throw(|| match call() { - Ok(()) => promise_undefined(), - Err(err_val) => promise_rejected(err_val), + Ok(()) => crate::fs::promise_undefined_fs(), + Err(err_val) => crate::fs::promise_rejected_fs(err_val), }) { Ok(promise) => promise, - Err(err) => promise_rejected(err), + Err(err) => crate::fs::promise_rejected_fs(err), } } fn promise_from_result_value(call: impl FnOnce() -> Result) -> f64 { match catch_fs_promises_throw(|| match call() { - Ok(value) => promise_value(value), - Err(err_val) => promise_rejected(err_val), + Ok(value) => crate::fs::promise_value_fs(value), + Err(err_val) => crate::fs::promise_rejected_fs(err_val), }) { Ok(promise) => promise, - Err(err) => promise_rejected(err), + Err(err) => crate::fs::promise_rejected_fs(err), } } @@ -128,12 +128,23 @@ pub(crate) extern "C" fn thunk_fs_promises_open( ) -> f64 { match catch_fs_promises_throw(|| { match unsafe { crate::fs::js_fs_filehandle_open_result(path, flags) } { - Ok(handle) => promise_value(handle), - Err(err_val) => promise_rejected(err_val), + Ok(handle) => { + 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.get_nanbox_f64() + } + Err(err_val) => crate::fs::promise_rejected_fs(err_val), } }) { Ok(promise) => promise, - Err(err) => promise_rejected(err), + Err(err) => crate::fs::promise_rejected_fs(err), } } @@ -146,12 +157,12 @@ pub(crate) extern "C" fn thunk_fs_promises_writeFile( ) -> f64 { match catch_fs_promises_throw(|| { match unsafe { crate::fs::write_file_path_or_fd_result(path, data, options) } { - Ok(()) => promise_undefined(), - Err(err) => promise_rejected(err), + Ok(()) => crate::fs::promise_undefined_fs(), + Err(err) => crate::fs::promise_rejected_fs(err), } }) { Ok(promise) => promise, - Err(err) => promise_rejected(err), + Err(err) => crate::fs::promise_rejected_fs(err), } } @@ -206,7 +217,7 @@ pub(crate) extern "C" fn thunk_fs_promises_lchmod( let msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); crate::node_submodules::register_error_code_pub(msg, "ERR_METHOD_NOT_IMPLEMENTED"); let err = crate::error::js_error_new_with_message(msg); - return promise_rejected(crate::value::js_nanbox_pointer(err as i64)); + return crate::fs::promise_rejected_fs(crate::value::js_nanbox_pointer(err as i64)); } promise_from_result_undefined(|| unsafe { crate::fs::js_fs_lchmod_result(path, mode) }) } @@ -390,7 +401,16 @@ pub(crate) extern "C" fn thunk_fs_promises_opendir( _closure: *const ClosureHeader, path: f64, ) -> f64 { - promise_from_result_value(|| crate::fs::js_fs_opendir_value_with_path(path)) + match crate::fs::js_fs_opendir_value_with_path(path) { + Ok(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), + } } pub(crate) extern "C" fn thunk_fs_promises_glob( diff --git a/crates/perry-runtime/src/node_vm.rs b/crates/perry-runtime/src/node_vm.rs index d6bf6490c5..5428e268aa 100644 --- a/crates/perry-runtime/src/node_vm.rs +++ b/crates/perry-runtime/src/node_vm.rs @@ -1263,6 +1263,14 @@ fn execute_in_state(source: &str, state: &ContextState) -> f64 { result } +/// Evaluate the expression payload of a runtime-loaded JavaScript data URL in +/// the main realm. Dynamic import lives outside this module, so keep the +/// interpreter entry narrow instead of exposing VM context internals. +#[cfg(feature = "dyn-eval")] +pub(crate) fn eval_dynamic_module_expression(source: &str) -> f64 { + execute_in_state(source, &main_context_state()) +} + fn script_metadata(script_value: f64) -> Option { object_ptr_from_value(script_value) .and_then(|ptr| scripts().lock().unwrap().get(&(ptr as usize)).cloned()) diff --git a/crates/perry-runtime/src/object/class_handles.rs b/crates/perry-runtime/src/object/class_handles.rs index 9df63256b1..e8f6cd32cc 100644 --- a/crates/perry-runtime/src/object/class_handles.rs +++ b/crates/perry-runtime/src/object/class_handles.rs @@ -100,6 +100,8 @@ pub type FetchHandleKindProbeFn = unsafe extern "C" fn(id: usize) -> u8; /// as heap objects. pub type EventEmitterHandleProbeFn = unsafe extern "C" fn(handle: i64) -> bool; pub type EventEmitterAsyncResourceHandleProbeFn = unsafe extern "C" fn(handle: i64) -> bool; +pub type EventEmitterAsyncResourceDispatchFn = + unsafe extern "C" fn(handle: i64, operation: u32) -> f64; pub type EventEmitterGetDomainFn = unsafe extern "C" fn(handle: i64) -> i64; pub type EventEmitterSetDomainFn = unsafe extern "C" fn(handle: i64, domain: i64) -> i32; @@ -161,6 +163,7 @@ static FETCH_HANDLE_KIND_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut static EVENT_EMITTER_HANDLE_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static EVENT_EMITTER_ASYNC_RESOURCE_HANDLE_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); +static EVENT_EMITTER_ASYNC_RESOURCE_DISPATCH_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static EVENT_EMITTER_GET_DOMAIN_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static EVENT_EMITTER_SET_DOMAIN_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); static NET_SOCKET_HANDLE_PROBE_PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); @@ -483,6 +486,23 @@ pub unsafe extern "C" fn js_register_event_emitter_async_resource_handle_probe( EVENT_EMITTER_ASYNC_RESOURCE_HANDLE_PROBE_PTR.store(f as *mut (), Ordering::Release); } +#[inline] +pub fn event_emitter_async_resource_dispatch() -> Option { + let p = EVENT_EMITTER_ASYNC_RESOURCE_DISPATCH_PTR.load(Ordering::Acquire); + if p.is_null() { + None + } else { + Some(unsafe { std::mem::transmute::<*mut (), EventEmitterAsyncResourceDispatchFn>(p) }) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_register_event_emitter_async_resource_dispatch( + f: EventEmitterAsyncResourceDispatchFn, +) { + EVENT_EMITTER_ASYNC_RESOURCE_DISPATCH_PTR.store(f as *mut (), Ordering::Release); +} + #[inline] pub fn event_emitter_get_domain() -> Option { let p = EVENT_EMITTER_GET_DOMAIN_PTR.load(Ordering::Acquire); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index d7ae50b51f..27e330209c 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -17,25 +17,28 @@ //! `pub use class_registry::*` glob in `object/mod.rs`). Pure relocation. pub use super::class_handles::{ - event_emitter_async_resource_handle_probe, event_emitter_get_domain, - event_emitter_handle_probe, event_emitter_on, event_emitter_set_domain, - fetch_handle_kind_probe, handle_method_dispatch, handle_own_property_names_dispatch, - handle_property_dispatch, handle_property_set_dispatch, handle_prototype_dispatch, - http_agent_handle_probe, js_register_event_emitter_async_resource_handle_probe, - js_register_event_emitter_get_domain, js_register_event_emitter_handle_probe, - js_register_event_emitter_on, js_register_event_emitter_set_domain, - js_register_fetch_handle_kind_probe, js_register_handle_method_dispatch, - js_register_handle_own_property_names_dispatch, js_register_handle_property_dispatch, - js_register_handle_property_set_dispatch, js_register_handle_prototype_dispatch, - js_register_http_agent_handle_probe, js_register_net_socket_handle_probe, - js_register_stream_expando_set, js_register_stream_handle_kind_probe, - js_register_stream_handle_probe, js_register_tls_handle_kind_probe, net_socket_handle_probe, - stream_expando_set, stream_handle_kind_probe, stream_handle_probe, tls_handle_kind_probe, - EventEmitterAsyncResourceHandleProbeFn, EventEmitterGetDomainFn, EventEmitterHandleProbeFn, - EventEmitterOnFn, EventEmitterSetDomainFn, FetchHandleKindProbeFn, HandleMethodDispatchFn, - HandleOwnPropertyNamesDispatchFn, HandlePropertyDispatchFn, HandlePropertySetDispatchFn, - HandlePrototypeDispatchFn, HttpAgentHandleProbeFn, NetSocketHandleProbeFn, - StreamHandleKindProbeFn, StreamHandleProbeFn, TlsHandleKindProbeFn, + event_emitter_async_resource_dispatch, event_emitter_async_resource_handle_probe, + event_emitter_get_domain, event_emitter_handle_probe, event_emitter_on, + event_emitter_set_domain, fetch_handle_kind_probe, handle_method_dispatch, + handle_own_property_names_dispatch, handle_property_dispatch, handle_property_set_dispatch, + handle_prototype_dispatch, http_agent_handle_probe, + js_register_event_emitter_async_resource_dispatch, + js_register_event_emitter_async_resource_handle_probe, js_register_event_emitter_get_domain, + js_register_event_emitter_handle_probe, js_register_event_emitter_on, + js_register_event_emitter_set_domain, js_register_fetch_handle_kind_probe, + js_register_handle_method_dispatch, js_register_handle_own_property_names_dispatch, + js_register_handle_property_dispatch, js_register_handle_property_set_dispatch, + js_register_handle_prototype_dispatch, js_register_http_agent_handle_probe, + js_register_net_socket_handle_probe, js_register_stream_expando_set, + js_register_stream_handle_kind_probe, js_register_stream_handle_probe, + js_register_tls_handle_kind_probe, net_socket_handle_probe, stream_expando_set, + stream_handle_kind_probe, stream_handle_probe, tls_handle_kind_probe, + EventEmitterAsyncResourceDispatchFn, EventEmitterAsyncResourceHandleProbeFn, + EventEmitterGetDomainFn, EventEmitterHandleProbeFn, EventEmitterOnFn, EventEmitterSetDomainFn, + FetchHandleKindProbeFn, HandleMethodDispatchFn, HandleOwnPropertyNamesDispatchFn, + HandlePropertyDispatchFn, HandlePropertySetDispatchFn, HandlePrototypeDispatchFn, + HttpAgentHandleProbeFn, NetSocketHandleProbeFn, StreamHandleKindProbeFn, StreamHandleProbeFn, + TlsHandleKindProbeFn, }; use super::*; diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index ee46eaf666..f48d3aa48c 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -125,11 +125,30 @@ pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, mut parent_val if !super::super::native_module::is_native_module_constructor_export(&module, &method) { throw_object_type_error(b"Class extends value is not a constructor"); } - if super::super::native_module::normalize_native_module_alias(&module) == "wasi" - && method == "WASI" - { + let module = super::super::native_module::normalize_native_module_alias(&module); + if module == "wasi" && method == "WASI" { register_class(class_id, crate::wasi::CLASS_ID_WASI); } + if module == "async_hooks" { + let parent = match method.as_str() { + "AsyncLocalStorage" => 0xFFFF0078, + "AsyncResource" => 0xFFFF0079, + _ => 0, + }; + if parent != 0 { + register_class(class_id, parent); + } + } + if module == "events" { + let parent = match method.as_str() { + "EventEmitter" => 0xFFFF0076, + "EventEmitterAsyncResource" => 0xFFFF0077, + _ => 0, + }; + if parent != 0 { + register_class(class_id, parent); + } + } return; } // Spec: a non-`null` superclass that is not a constructor throws a TypeError @@ -1759,6 +1778,10 @@ pub fn method_owner_class_id(class_id: u32, name: &str) -> Option { None } +#[cfg(test)] +#[path = "parent_static/unstamped_tests.rs"] +mod unstamped_tests; + #[cfg(test)] mod shape_authority_tests_8067 { fn key<'scope>( @@ -1779,70 +1802,6 @@ mod shape_authority_tests_8067 { super::js_object_mark_class(1); } - /// #8113 replaces #8067's "saved lineage beats an interim self-heal" test. - /// - /// The self-heal it modelled is GONE: `typed_feedback::object_shape` used to - /// mint a lineage-free descriptor for an unstamped receiver, which under - /// #8113 would also publish a live inline-slot bound of ZERO — a read-only - /// observation path silently truncating the object's payload. The property - /// worth pinning is now the stronger one: an unstamped receiver MISSES, and - /// observing it publishes nothing at all. - /// - /// The clear here is manufactured with a test-only helper. No production - /// path clears a stamp any more (`shapes::clear_object_shape_stamp` is - /// `#[cfg(test)]`), which is what makes the window this used to model - /// unreachable rather than merely narrow. - #[test] - fn an_unstamped_receiver_misses_instead_of_being_self_healed() { - let _lock = crate::gc::global_side_table_test_lock(); - unsafe { - const CID: u32 = 0x8068; - let scope = crate::gc::RuntimeHandleScope::new(); - let obj_handle = scope.root_raw_mut_ptr(crate::object::js_object_alloc(CID, 1)); - let ((), obj) = obj_handle.across_mut::(|| { - obj_handle.with_mut_ptr::(|obj| { - super::js_object_mark_class(obj as i64) - }) - }); - let predecessor = crate::object::shapes::object_shape_descriptor(obj) - .expect("marked class descriptor"); - assert_eq!( - predecessor.object_kind, - crate::object::shapes::ShapeObjectKind::Class - ); - assert_eq!(predecessor.live_inline_slot_count, 1); - - assert!(crate::object::shapes::clear_object_shape_stamp(obj)); - let (interim, obj) = obj_handle.across_mut::(|| { - crate::typed_feedback::test_object_shape_token(obj as usize) - }); - assert_eq!( - interim, 0, - "an unstamped receiver must MISS; minting a lineage-free \ - descriptor for it would publish a zero live-slot bound" - ); - assert!( - crate::object::shapes::object_shape_descriptor(obj).is_none(), - "observing an unstamped receiver must not publish a descriptor" - ); - - // The mutator's saved lineage still restores both facts exactly. - crate::object::shapes::synchronize_object_shape_descriptor_from( - obj, - Some(predecessor), - predecessor.live_inline_slot_count, - ); - let restored = - crate::object::shapes::object_shape_descriptor(obj).expect("restored descriptor"); - assert_eq!( - restored.object_kind, - crate::object::shapes::ShapeObjectKind::Class, - "the mutator's saved semantic lineage must survive the window" - ); - assert_eq!(restored.live_inline_slot_count, 1); - } - } - #[test] fn class_kind_survives_static_field_installation_and_deletion() { let _lock = crate::gc::global_side_table_test_lock(); diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/unstamped_tests.rs b/crates/perry-runtime/src/object/class_registry/parent_static/unstamped_tests.rs new file mode 100644 index 0000000000..a0ba95e0a9 --- /dev/null +++ b/crates/perry-runtime/src/object/class_registry/parent_static/unstamped_tests.rs @@ -0,0 +1,46 @@ +/// #8113 replaces #8067's "saved lineage beats an interim self-heal" test. +/// An unstamped receiver must miss without publishing a lineage-free shape. +#[test] +fn an_unstamped_receiver_misses_instead_of_being_self_healed() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + const CID: u32 = 0x8068; + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_mut_ptr(crate::object::js_object_alloc(CID, 1)); + let ((), obj) = obj_handle.across_mut::(|| { + obj_handle.with_mut_ptr::(|obj| { + super::js_object_mark_class(obj as i64) + }) + }); + let predecessor = + crate::object::shapes::object_shape_descriptor(obj).expect("marked class descriptor"); + assert_eq!( + predecessor.object_kind, + crate::object::shapes::ShapeObjectKind::Class + ); + assert_eq!(predecessor.live_inline_slot_count, 1); + + assert!(crate::object::shapes::clear_object_shape_stamp(obj)); + let (interim, obj) = obj_handle.across_mut::(|| { + crate::typed_feedback::test_object_shape_token(obj as usize) + }); + assert_eq!( + interim, 0, + "an unstamped receiver must miss instead of publishing a zero-slot shape" + ); + assert!(crate::object::shapes::object_shape_descriptor(obj).is_none()); + + crate::object::shapes::synchronize_object_shape_descriptor_from( + obj, + Some(predecessor), + predecessor.live_inline_slot_count, + ); + let restored = + crate::object::shapes::object_shape_descriptor(obj).expect("restored descriptor"); + assert_eq!( + restored.object_kind, + crate::object::shapes::ShapeObjectKind::Class + ); + assert_eq!(restored.live_inline_slot_count, 1); + } +} diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 2076a32439..3fe6bc5fd1 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -620,7 +620,10 @@ pub(crate) unsafe fn nm_ee_prototype_install( ) { proto.with_mut_ptr::(|proto| { - crate::node_stream::install_event_emitter_prototype_methods(proto) + crate::node_stream::install_event_emitter_prototype_methods(proto); + if method == "EventEmitterAsyncResource" { + crate::node_stream::install_event_emitter_async_resource_prototype(proto); + } }); } } diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 693c614547..a0d0689350 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -1901,7 +1901,7 @@ pub(crate) unsafe fn nm_get_own_descriptor( .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)); return Some(build_data_descriptor(value, false, true, false)); } - if module_name == "module" { + if matches!(module_name.as_str(), "module" | "async_hooks") { return Some(build_data_descriptor( f64::from_bits(value.bits()), true, diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 64ae285ea0..89c5a87662 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -204,6 +204,7 @@ pub(crate) mod enumeration; mod field_ops; mod for_in_stable; mod get_field_by_name; +mod get_field_by_name_async; #[cfg(test)] mod get_field_by_name_probe_tests; mod get_field_by_name_tail; @@ -269,6 +270,7 @@ pub use field_ops::{ }; pub use for_in_stable::js_for_in_keys_stable_value; pub use get_field_by_name::js_object_get_field_by_name; +pub(crate) use get_field_by_name_async::async_resource_property; pub(crate) use get_field_by_name_tail::get_field_by_name_object_tail; pub(super) use has_property::native_module_own_field_by_key; pub(crate) use has_property::{ diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 475a40f87b..1c10f88b50 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -539,7 +539,7 @@ pub extern "C" fn js_object_get_field_by_name( { if !key.is_null() { unsafe { - let key_ptr = (key as *const u8).add(std::mem::size_of::()); + let key_ptr = crate::string::string_data(key); let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); let ta = addr as *const crate::typedarray::TypedArrayHeader; @@ -869,6 +869,20 @@ pub extern "C" fn js_object_get_field_by_name( } else { 0 }; + if raw != 0 && !key.is_null() { + unsafe { + let key_ptr = (key as *const u8).add(std::mem::size_of::()); + let key_len = (*key).byte_len as usize; + if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)) + { + if let Some(value) = + crate::async_hooks::try_async_resource_property_dispatch(raw as i64, name) + { + return JSValue::from_bits(value.to_bits()); + } + } + } + } if crate::value::addr_class::is_small_handle(raw) { if !key.is_null() { unsafe { @@ -876,6 +890,11 @@ pub extern "C" fn js_object_get_field_by_name( (key as *const u8).add(std::mem::size_of::()); let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); + if key_bytes == b"constructor" { + if let Some(value) = crate::timer::timer_constructor_value(raw as i64) { + return JSValue::from_bits(value.to_bits()); + } + } if let Some(method) = timer_handle_method_name_static(key_bytes) { if crate::timer::is_known_timer_id(raw as i64) { let this_f64 = f64::from_bits( diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_async.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_async.rs new file mode 100644 index 0000000000..427f7c4832 --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_async.rs @@ -0,0 +1,17 @@ +use super::*; + +/// Resolve expandos on registry-backed AsyncResource handles before ordinary +/// object/handle property dispatch. Copy the key payload so hook code cannot +/// invalidate a borrowed slice by triggering a moving collection. +pub(crate) fn async_resource_property( + obj: *const ObjectHeader, + key: *const crate::StringHeader, +) -> Option { + if key.is_null() { + return None; + } + let key = unsafe { crate::string::OwnedStringBytes::copy_from_header(key) }; + let name = std::str::from_utf8(key.as_bytes()).ok()?; + crate::async_hooks::try_async_resource_property_dispatch(obj as i64, name) + .map(|value| JSValue::from_bits(value.to_bits())) +} diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index da053cb3aa..2e1cb30b9d 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -22,6 +22,9 @@ pub(crate) fn get_field_by_name_object_tail( // undefined/null tag or null pointer — return undefined return JSValue::undefined(); } + if let Some(value) = async_resource_property(raw, key) { + return value; + } // Issue #340: small-handle receivers (raw < 0x100000) come // from native modules (axios, fastify, ioredis, ...) that // store objects in registries and expose integer ids. The @@ -38,6 +41,11 @@ pub(crate) fn get_field_by_name_object_tail( (key as *const u8).add(std::mem::size_of::()); let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); + if key_bytes == b"constructor" { + if let Some(value) = crate::timer::timer_constructor_value(raw as i64) { + return JSValue::from_bits(value.to_bits()); + } + } if let Some(method) = timer_handle_method_name_static(key_bytes) { if crate::timer::is_known_timer_id(raw as i64) { let this_f64 = f64::from_bits( @@ -107,6 +115,9 @@ pub(crate) fn get_field_by_name_object_tail( if obj.is_null() { return JSValue::undefined(); } + if let Some(value) = async_resource_property(obj, key) { + return value; + } // Same handle-receiver path for already-stripped pointers — happens // when the codegen passes a raw i64 handle through the slow path. if crate::value::addr_class::is_handle_band(obj as usize) { @@ -115,6 +126,11 @@ pub(crate) fn get_field_by_name_object_tail( let key_ptr = (key as *const u8).add(std::mem::size_of::()); let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); + if key_bytes == b"constructor" { + if let Some(value) = crate::timer::timer_constructor_value(obj as i64) { + return JSValue::from_bits(value.to_bits()); + } + } if let Some(method) = timer_handle_method_name_static(key_bytes) { if crate::timer::is_known_timer_id(obj as i64) { let this_f64 = diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index d677f3c552..f6f1d9949d 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -480,6 +480,19 @@ pub extern "C" fn js_object_get_field_ic_miss( // `< 0x100000` proxy / HANDLE_PROPERTY_DISPATCH routing below — matching // the ordering in `js_object_get_field_by_name`. The macOS heap floor // (0x200_0000_0000 in is_valid_obj_ptr) masked this; Linux's is 0x1000. + if !key.is_null() { + unsafe { + let key_ptr = crate::string::string_data(key); + let key_len = (*key).byte_len as usize; + if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(key_ptr, key_len)) { + if let Some(value) = + crate::async_hooks::try_async_resource_property_dispatch(obj as i64, name) + { + return value; + } + } + } + } if crate::value::addr_class::is_above_handle_band(obj as usize) { // #7753: `arr.length` on a receiver codegen could not prove is an array. // @@ -559,6 +572,11 @@ pub extern "C" fn js_object_get_field_ic_miss( let key_ptr = (key as *const u8).add(std::mem::size_of::()); let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); + if key_bytes == b"constructor" { + if let Some(value) = crate::timer::timer_constructor_value(obj as i64) { + return value; + } + } if let Some(method) = timer_handle_method_name_static(key_bytes) { if crate::timer::is_known_timer_id(obj as i64) { let this_f64 = diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index b40b84375a..e45fa4ecd8 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -8,6 +8,8 @@ use super::*; // Keep in sync with perry-codegen/src/expr/instance_misc1.rs. const CLASS_ID_EVENT_EMITTER: u32 = 0xFFFF0076; const CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE: u32 = 0xFFFF0077; +const CLASS_ID_ASYNC_LOCAL_STORAGE: u32 = 0xFFFF0078; +const CLASS_ID_ASYNC_RESOURCE: u32 = 0xFFFF0079; const CLASS_ID_PROMISE: u32 = 0xFFFF0027; const CLASS_ID_NET_SOCKET: u32 = 0xFFFF00B4; const CLASS_ID_CRYPTO: u32 = 0xFFFF00C0; @@ -336,6 +338,34 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { { return f64::from_bits(crate::value::TAG_TRUE); } + if module == "async_hooks" + && matches!(method.as_str(), "AsyncLocalStorage" | "AsyncResource") + { + let raw = value_addr(value); + let matched = if method == "AsyncResource" { + crate::async_hooks::resolve_async_resource_handle(raw as i64).is_some() + || (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); + let native = (candidate != 0) && { + super::class_handles::handle_property_dispatch().is_some_and(|dispatch| { + let property = b"getStore"; + let result = + unsafe { dispatch(candidate, property.as_ptr(), property.len()) }; + value_is_callable(result) + }) + }; + native + || (crate::value::addr_class::is_plausible_heap_addr(raw) + && ordinary_has_instance_prototype_walk(value, type_ref)) + }; + return f64::from_bits(if matched { + crate::value::TAG_TRUE + } else { + TAG_FALSE + }); + } if module == "tty" && matches!(method.as_str(), "ReadStream" | "WriteStream") && crate::tty::is_tty_stream_instance(value, method.as_str()) @@ -1140,6 +1170,26 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { false_val }; } + if class_id == CLASS_ID_ASYNC_RESOURCE { + return if crate::async_hooks::resolve_async_resource_handle(value_addr(value) as i64) + .is_some() + { + true_val + } else { + false_val + }; + } + if class_id == CLASS_ID_ASYNC_LOCAL_STORAGE { + let candidate = small_native_handle_id(value).unwrap_or(value_addr(value) as i64); + let matched = candidate != 0 && { + super::class_handles::handle_property_dispatch().is_some_and(|dispatch| { + let property = b"getStore"; + let result = unsafe { dispatch(candidate, property.as_ptr(), property.len()) }; + value_is_callable(result) + }) + }; + return if matched { true_val } else { false_val }; + } if class_id == CLASS_ID_NET_SOCKET { return if let Some(handle) = small_native_handle_id(value) { let net_socket = crate::object::net_socket_handle_probe() diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index da1923b313..2cf6d643bd 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -17,6 +17,7 @@ use std::sync::{ }; mod async_hooks_exports; +pub(crate) use async_hooks_exports::async_resource_prototype_method_value; mod callable_export_arity_table; mod callable_export_check; mod callable_export_table; @@ -447,13 +448,29 @@ static NM_NAMESPACE_OPS_IMPL: super::NmNamespaceOps = super::NmNamespaceOps { /// Dynamic-`super()` EventEmitter-subclass init (extracted from /// `closure::dispatch::value_call`; see `NmNamespaceOps::ee_dynamic_super`). -unsafe fn nm_ee_dynamic_super(func_value: f64) -> Option { +unsafe fn nm_ee_dynamic_super( + func_value: f64, + args_ptr: *const f64, + args_len: usize, +) -> Option { let (module, method) = bound_native_callable_module_and_method(func_value)?; if module.trim_start_matches("node:") == "events" && (method == "EventEmitter" || method == "EventEmitterAsyncResource") { let this_val = super::js_implicit_this_get(); if crate::value::JSValue::from_bits(this_val.to_bits()).is_pointer() { + if method == "EventEmitterAsyncResource" { + let options = if !args_ptr.is_null() && args_len > 0 { + *args_ptr + } else { + f64::from_bits(crate::value::TAG_UNDEFINED) + }; + return Some( + crate::node_stream::js_event_emitter_async_resource_subclass_init( + this_val, options, + ), + ); + } return Some(crate::node_stream::js_event_emitter_subclass_init(this_val)); } } diff --git a/crates/perry-runtime/src/object/native_module/async_hooks_exports.rs b/crates/perry-runtime/src/object/native_module/async_hooks_exports.rs index 43db54c96a..171cd7f209 100644 --- a/crates/perry-runtime/src/object/native_module/async_hooks_exports.rs +++ b/crates/perry-runtime/src/object/native_module/async_hooks_exports.rs @@ -52,6 +52,30 @@ extern "C" fn async_hooks_prototype_method_thunk( } let args_array = crate::value::js_nanbox_get_pointer(rest); + let receiver_raw = if receiver.to_bits() >> 48 == 0x7FFD { + (receiver.to_bits() & crate::value::POINTER_MASK) as i64 + } else { + 0 + }; + let args = if args_array == 0 { + Vec::new() + } else { + let array = args_array as *const crate::array::ArrayHeader; + let len = crate::array::js_array_length(array) as usize; + (0..len) + .map(|index| f64::from_bits(crate::array::js_array_get(array, index as u32).bits())) + .collect::>() + }; + if let Ok(name) = std::str::from_utf8(name) { + if let Some(result) = crate::async_hooks::try_async_resource_method_dispatch( + receiver_raw, + name, + args.as_ptr(), + args.len(), + ) { + return result; + } + } crate::object::js_native_call_method_apply(receiver, name_ptr, name_len, args_array) } } @@ -164,6 +188,23 @@ fn attach_prototype(constructor_value: f64, methods: &[(&str, u32)]) -> f64 { ) } +/// Materialize an unbound `AsyncResource.prototype` method for native-handle +/// property reads whose static type was erased. Invocation observes the +/// call-site receiver through `IMPLICIT_THIS`, just like the real prototype. +pub(crate) fn async_resource_prototype_method_value(name: &'static str, length: u32) -> f64 { + let thunk = async_hooks_prototype_method_thunk as *const u8; + crate::closure::js_register_closure_rest(thunk, 0); + let closure = crate::closure::js_closure_alloc(thunk, 2); + if closure.is_null() { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + crate::closure::js_closure_set_capture_ptr(closure, 0, name.as_ptr() as i64); + crate::closure::js_closure_set_capture_ptr(closure, 1, name.len() as i64); + super::callable_exports::set_builtin_closure_length(closure as usize, length); + super::callable_exports::set_bound_native_closure_name(closure, name); + crate::value::js_nanbox_pointer(closure as i64) +} + pub(super) fn attach_async_local_storage_prototype(constructor_value: f64) -> f64 { attach_prototype(constructor_value, ASYNC_LOCAL_STORAGE_METHODS) } 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 93174de757..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 @@ -152,6 +152,18 @@ pub(crate) unsafe fn nm_dispatch_async_hooks( typed_kind ); match (module_name, method_name) { + ("async_hooks", "AsyncLocalStorage") | ("async_hooks", "AsyncResource") => { + let message = + format!("Class constructor {method_name} cannot be invoked without 'new'"); + 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") => { ptr_to_f64(crate::async_hooks::js_async_hooks_create_hook(arg(0)) as *const u8) } diff --git a/crates/perry-runtime/src/object/nm_namespace_hooks.rs b/crates/perry-runtime/src/object/nm_namespace_hooks.rs index ac06fe107f..abcfbf4c9d 100644 --- a/crates/perry-runtime/src/object/nm_namespace_hooks.rs +++ b/crates/perry-runtime/src/object/nm_namespace_hooks.rs @@ -51,7 +51,7 @@ pub(crate) struct NmNamespaceOps { /// Dynamic `super()` for `class X extends `: /// installs the EE methods on the fresh instance. `None` when the callee /// is not the bound events export (fall through to normal call dispatch). - pub ee_dynamic_super: unsafe fn(f64) -> Option, + pub ee_dynamic_super: unsafe fn(f64, *const f64, usize) -> Option, } static NM_NAMESPACE_OPS: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); diff --git a/crates/perry-runtime/src/object/reflect_support.rs b/crates/perry-runtime/src/object/reflect_support.rs index faae487187..6963e1f73f 100644 --- a/crates/perry-runtime/src/object/reflect_support.rs +++ b/crates/perry-runtime/src/object/reflect_support.rs @@ -43,8 +43,12 @@ pub(crate) fn obj_value_no_extend(value: f64) -> bool { pub(crate) fn obj_value_has_own_key(value: f64, key: f64) -> bool { unsafe { if crate::symbol::js_is_symbol(key) != 0 { - let v = crate::symbol::js_object_get_symbol_property(value, key); - return v.to_bits() != crate::value::TAG_UNDEFINED; + // Presence cannot be inferred from the value: an own Symbol-keyed + // data property is allowed to contain `undefined`. The old value + // probe therefore turned an existing writable property into an + // apparent miss, which made OrdinarySet drop the first metadata + // overwrite on native AsyncResource handles. + return crate::symbol::has_own_symbol_property(value, key); } let obj = extract_obj_ptr(value); if obj.is_null() { diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index 2b9b37f0c4..2f2e90a351 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -652,6 +652,34 @@ fn symbol_keys_keep_creation_order_across_accessor_redefine() { } } +#[test] +fn undefined_symbol_value_still_counts_as_an_own_property() { + let _global = crate::gc::global_side_table_test_lock(); + crate::symbol::test_clear_symbol_side_table_roots(); + unsafe { + let obj = js_object_alloc(0, 0); + assert!(!obj.is_null()); + let obj_value = crate::value::js_nanbox_pointer(obj as i64); + let symbol = crate::symbol::js_symbol_new_empty(); + crate::symbol::js_object_set_symbol_property( + obj_value, + symbol, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + + assert!(crate::symbol::has_own_symbol_property(obj_value, symbol)); + assert!(super::reflect_support::obj_value_has_own_key( + obj_value, symbol + )); + crate::proxy::js_put_value_set(obj_value, symbol, 42.0, obj_value, 1); + assert_eq!( + crate::symbol::js_object_get_symbol_property(obj_value, symbol).to_bits(), + 42.0f64.to_bits(), + "OrdinarySet must overwrite an own Symbol property whose old value is undefined" + ); + } +} + /// #7916 / #8047: the per-object footprint accounting this issue is about, /// pinned as an executable fact rather than a comment. /// diff --git a/crates/perry-runtime/src/object/to_string_tag.rs b/crates/perry-runtime/src/object/to_string_tag.rs index 842e574db8..2b1bdd5770 100644 --- a/crates/perry-runtime/src/object/to_string_tag.rs +++ b/crates/perry-runtime/src/object/to_string_tag.rs @@ -436,7 +436,7 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 { /// caller's `None` arm. fn native_module_to_string_tag(module: &str) -> Option<&'static str> { match module { - "module" => Some("Module"), + "module" | "async_hooks" => Some("Module"), // `Object.prototype.toString.call(performance)` is // "[object Performance]" in Node. "perf_hooks" => Some("Performance"), diff --git a/crates/perry-runtime/src/os/signal.rs b/crates/perry-runtime/src/os/signal.rs index d567c5f657..b2f1becdca 100644 --- a/crates/perry-runtime/src/os/signal.rs +++ b/crates/perry-runtime/src/os/signal.rs @@ -1,10 +1,15 @@ use crate::fs::validate::{describe_received, is_numeric, throw_type_error_with_code}; use crate::string::{js_string_from_bytes, StringHeader}; use crate::value::{JSValue, TAG_TRUE}; +use std::collections::HashMap; #[cfg(unix)] use std::sync::atomic::AtomicI32; #[cfg(any(unix, windows))] use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{LazyLock, Mutex}; + +static SIGNAL_ASYNC_IDS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); fn signal_number_by_name(name: &str) -> Option { #[cfg(unix)] @@ -92,6 +97,18 @@ static SIGQUIT_LISTENERS: AtomicUsize = AtomicUsize::new(0); #[cfg(unix)] static SIGQUIT_INSTALLED: AtomicBool = AtomicBool::new(false); #[cfg(unix)] +static SIGUSR1_PENDING: AtomicUsize = AtomicUsize::new(0); +#[cfg(unix)] +static SIGUSR1_LISTENERS: AtomicUsize = AtomicUsize::new(0); +#[cfg(unix)] +static SIGUSR1_INSTALLED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] +static SIGUSR2_PENDING: AtomicUsize = AtomicUsize::new(0); +#[cfg(unix)] +static SIGUSR2_LISTENERS: AtomicUsize = AtomicUsize::new(0); +#[cfg(unix)] +static SIGUSR2_INSTALLED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] static SIGABRT_PENDING: AtomicUsize = AtomicUsize::new(0); #[cfg(unix)] static SIGABRT_LISTENERS: AtomicUsize = AtomicUsize::new(0); @@ -148,6 +165,20 @@ static PROCESS_SIGNAL_SLOTS: &[ProcessSignalSlot] = &[ listeners: &SIGQUIT_LISTENERS, installed: &SIGQUIT_INSTALLED, }, + ProcessSignalSlot { + name: "SIGUSR1", + number: libc::SIGUSR1, + pending: &SIGUSR1_PENDING, + listeners: &SIGUSR1_LISTENERS, + installed: &SIGUSR1_INSTALLED, + }, + ProcessSignalSlot { + name: "SIGUSR2", + number: libc::SIGUSR2, + pending: &SIGUSR2_PENDING, + listeners: &SIGUSR2_LISTENERS, + installed: &SIGUSR2_INSTALLED, + }, ProcessSignalSlot { name: "SIGABRT", number: libc::SIGABRT, @@ -494,6 +525,29 @@ pub(crate) fn set_process_signal_listener_count(name: &str, count: usize) { { let _ = (name, count); } + + if count > 0 { + let needs_resource = !SIGNAL_ASYNC_IDS.lock().unwrap().contains_key(name); + if needs_resource { + let resource = crate::object::js_object_alloc_null_proto(0, 0); + let async_id = crate::async_hooks::init_resource( + "SIGNALWRAP", + crate::value::js_nanbox_pointer(resource as i64), + true, + ) + .async_id; + SIGNAL_ASYNC_IDS + .lock() + .unwrap() + .entry(name.to_string()) + .or_insert(async_id); + } + } else { + let async_id = SIGNAL_ASYNC_IDS.lock().unwrap().remove(name); + if let Some(async_id) = async_id { + crate::async_hooks::destroy(async_id); + } + } } /// Whether a *pending, undelivered* signal is waiting to be drained. diff --git a/crates/perry-runtime/src/promise/assimilate.rs b/crates/perry-runtime/src/promise/assimilate.rs index 6e1a1bbd72..e499d6f52d 100644 --- a/crates/perry-runtime/src/promise/assimilate.rs +++ b/crates/perry-runtime/src/promise/assimilate.rs @@ -270,14 +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(), + 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 510cf31bee..08af32de52 100644 --- a/crates/perry-runtime/src/promise/async_step.rs +++ b/crates/perry-runtime/src/promise/async_step.rs @@ -295,7 +295,7 @@ pub extern "C" fn js_promise_resolved_then( // The primitive fast path below bypasses `js_promise_new`/`then`, so it // would never fire `v8.promiseHooks` (#3139). When hooks are active, route // through the real resolve+then path instead. - if crate::v8::promise_hooks_active() { + if crate::v8::promise_hooks_active() || crate::async_hooks::promise_hooks_active() { bump(&MT_FAST_PATH_MISS); let p1 = js_promise_resolved(value); return js_promise_then(p1, on_fulfilled, on_rejected); @@ -510,24 +510,46 @@ pub extern "C" fn js_async_step_chain(value: f64, step_closure: ClosurePtr) -> * let can_reuse = !trap.trap_next.is_null() && trap.current_step == step_closure as usize; let trap_next = trap.trap_next; - let (next, queued_value, is_error) = if is_definitely_primitive(value) { + let async_hooks_active = crate::async_hooks::promise_hooks_active(); + let (next, queued_value, is_error, direct_trigger_async_id) = if is_definitely_primitive(value) + { // Primitive value: enqueue Task::AsyncStep directly. bump(&MT_FAST_PATH_HIT); + // The optimized path skips the `PromiseResolve(%Promise%, value)` + // Promise that Node creates for `await value`. That Promise is still + // observable through async_hooks, and the continuation Promise must + // name it as its trigger. Materialize it only while Promise hooks are + // active; the normal no-hook fast path remains allocation-free. + let awaited = if async_hooks_active { + js_promise_resolved(value) + } else { + std::ptr::null_mut() + }; + let awaited_async_id = if awaited.is_null() { + 0 + } else { + unsafe { (*awaited).async_id } + }; ( if can_reuse { bump(&MT_STEP_CHAIN_REUSE_HIT); trap_next + } else if !awaited.is_null() { + bump(&MT_STEP_CHAIN_REUSE_MISS); + super::then::js_promise_new_with_parent(awaited) } else { bump(&MT_STEP_CHAIN_REUSE_MISS); js_promise_new() }, value, false, + awaited_async_id, ) } else if js_value_is_promise(value) != 0 { let inner = crate::value::js_nanbox_get_pointer(value) as *mut Promise; if !inner.is_null() { let inner_state = unsafe { (*inner).state }; + let inner_async_id = unsafe { (*inner).async_id }; match inner_state { PromiseState::Fulfilled => { // Inner already settled with a primitive (the steady @@ -539,12 +561,16 @@ pub extern "C" fn js_async_step_chain(value: f64, step_closure: ClosurePtr) -> * if can_reuse { bump(&MT_STEP_CHAIN_REUSE_HIT); trap_next + } else if async_hooks_active { + bump(&MT_STEP_CHAIN_REUSE_MISS); + super::then::js_promise_new_with_parent(inner) } else { bump(&MT_STEP_CHAIN_REUSE_MISS); js_promise_new() }, unwrapped, false, + inner_async_id, ) } PromiseState::Rejected => { @@ -564,12 +590,16 @@ pub extern "C" fn js_async_step_chain(value: f64, step_closure: ClosurePtr) -> * if can_reuse { bump(&MT_STEP_CHAIN_REUSE_HIT); trap_next + } else if async_hooks_active { + bump(&MT_STEP_CHAIN_REUSE_MISS); + super::then::js_promise_new_with_parent(inner) } else { bump(&MT_STEP_CHAIN_REUSE_MISS); js_promise_new() }, reason, true, + inner_async_id, ) } PromiseState::Pending => { @@ -629,6 +659,33 @@ pub extern "C" fn js_async_step_chain(value: f64, step_closure: ClosurePtr) -> * crate::value::js_nanbox_get_pointer(v) as *mut Promise } }; + let (step_async_id, step_trigger_id) = if next.is_null() { + (0, 0) + } else if can_reuse && async_hooks_active { + let resource = crate::value::js_nanbox_pointer(next as i64); + let trigger_async_id = if direct_trigger_async_id != 0 { + direct_trigger_async_id + } else { + crate::async_hooks::execution_async_id_u64() + }; + let ids = crate::async_hooks::init_resource_with_trigger( + "PROMISE", + resource, + false, + trigger_async_id, + ); + (ids.async_id, ids.trigger_async_id) + } 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( @@ -638,6 +695,8 @@ pub extern "C" fn js_async_step_chain(value: f64, step_closure: ClosurePtr) -> * is_error, context, trap.box_activation, + step_async_id, + step_trigger_id, )); }); crate::event_pump::js_notify_promise_progress(); diff --git a/crates/perry-runtime/src/promise/microtasks.rs b/crates/perry-runtime/src/promise/microtasks.rs index 84f7813443..12df3176fe 100644 --- a/crates/perry-runtime/src/promise/microtasks.rs +++ b/crates/perry-runtime/src/promise/microtasks.rs @@ -348,8 +348,12 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { // keep the macrotask-boundary ticks-first ordering. let ticks_allowed = !matches!(mode, MicrotaskDrainMode::PromiseJobsOnly); let mid_promise_job = CURRENT_MICROTASK_CALLBACK.with(|c| !c.get().is_null()); + let esm_checkpoint = ticks_allowed && consume_esm_eval_checkpoint(); + if esm_checkpoint { + crate::async_hooks::init_esm_evaluation_promise(); + } let mut esm_defer_tick_drain = if ticks_allowed { - consume_esm_eval_checkpoint() || (reentrant && mid_promise_job) + esm_checkpoint || (reentrant && mid_promise_job) } else { // PromiseJobsOnly never drains ticks; leave the one-shot ESM flag // for the first real checkpoint to consume. @@ -397,16 +401,31 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { // No callback registered → propagate the value/reason // to the next promise without invoking anything. if callback.is_null() { + let async_id = if (*promise).next.is_null() { + 0 + } else { + (*(*promise).next).async_id + }; + let trigger_async_id = if (*promise).next.is_null() { + 0 + } else { + (*(*promise).next).trigger_async_id + }; CURRENT_MICROTASK_PROMISE.with(|c| c.set(promise)); CURRENT_MICROTASK_VALUE.with(|c| c.set(value)); CURRENT_MICROTASK_NEXT.with(|c| c.set((*promise).next)); - if !(*promise).next.is_null() { + crate::async_hooks::before_promise(async_id, trigger_async_id); + 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); let promise = CURRENT_MICROTASK_PROMISE.with(|c| c.replace(std::ptr::null_mut())); CURRENT_MICROTASK_VALUE.with(|c| c.set(0.0)); @@ -468,15 +487,22 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { } else { None }; - // #1663: capture async_id + trigger as plain values BEFORE - // the callback. They are immutable for the promise's life, - // and the callback can re-entrantly drain microtasks (which - // can move the promise via GC or realloc the GC-root handle - // stack). Reading `(*promise).async_id` AFTER the callback - // to feed `after()` was the exact deref that segfaulted; use - // the captured value so `after()` needs no live promise. - let async_id = (*promise).async_id; - let trigger_async_id = (*promise).trigger_async_id; + // A Promise reaction executes as the child Promise + // returned by `.then()`, not as its parent. The parent + // owns the callback slot in Perry, but Node exposes the + // child's id/resource to before/after and + // executionAsyncResource(). Capture those child ids as + // plain values before user code can move the heap. + let async_id = if (*promise).next.is_null() { + 0 + } else { + (*(*promise).next).async_id + }; + let trigger_async_id = if (*promise).next.is_null() { + 0 + } else { + (*(*promise).next).trigger_async_id + }; crate::async_hooks::before_promise(async_id, trigger_async_id); let promise = promise_handle.get_raw_mut_ptr::(); crate::v8::promise_hook_before(promise); @@ -571,12 +597,25 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { let callback = rooted_closure(&callback_handle); let value = value_handle.get_nanbox_f64(); let next = rooted_promise(&next_handle); + let async_id = if next.is_null() { + 0 + } else { + unsafe { (*next).async_id } + }; + let trigger_async_id = if next.is_null() { + 0 + } else { + unsafe { (*next).trigger_async_id } + }; + crate::async_hooks::before_promise(async_id, trigger_async_id); // Inline tasks are produced by `js_promise_resolved_then` // (the `Promise.resolve().then(cb_f, cb_e)` // fast path). We've already skipped allocating the // 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); @@ -584,6 +623,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { js_promise_reject(next, value); } } + crate::async_hooks::after_promise(async_id); restore_microtask_context(); ran += 1; continue; @@ -623,13 +663,14 @@ 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()); CURRENT_MICROTASK_VALUE.with(|c| c.set(result)); let next_for_after = CURRENT_MICROTASK_NEXT.with(|c| c.get()); crate::v8::promise_hook_after(next_for_after); + crate::async_hooks::after_promise(async_id); if let Some(t) = t1 { MT_TIME_NS_CALLBACK .fetch_add(t.elapsed().as_nanos() as u64, Ordering::Relaxed); @@ -708,6 +749,8 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { is_error, task_context, box_activation, + step_async_id, + step_trigger_id, )) => { bump(&MT_RUN_COUNT); // The popped Task's activation reference transfers to the @@ -742,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); @@ -749,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); @@ -901,16 +948,6 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { // `next` via GC, #1663) and feed the same id to `after()`. // `before`/`after` early-return on id 0, so this is a no-op // when async_hooks are inactive. - let step_async_id = if next.is_null() { - 0 - } else { - unsafe { (*next).async_id } - }; - let step_trigger_id = if next.is_null() { - 0 - } else { - unsafe { (*next).trigger_async_id } - }; crate::async_hooks::before_promise(step_async_id, step_trigger_id); let next = rooted_promise(&next_handle); crate::v8::promise_hook_before(next); diff --git a/crates/perry-runtime/src/promise/mod.rs b/crates/perry-runtime/src/promise/mod.rs index 6782f06af6..3d31310e26 100644 --- a/crates/perry-runtime/src/promise/mod.rs +++ b/crates/perry-runtime/src/promise/mod.rs @@ -542,7 +542,9 @@ pub struct Promise { pub(crate) on_rejected: ClosurePtr, /// Next promise in the chain (for .then()) pub(crate) next: *mut Promise, - /// async_hooks asyncId for this Promise, 0 when hooks were inactive. + /// Stable async_hooks asyncId for this Promise. IDs are reserved even + /// before hooks are enabled so a later reaction can still point at its + /// pre-created parent. pub(crate) async_id: u64, /// async_hooks triggerAsyncId captured at Promise creation. pub(crate) trigger_async_id: u64, @@ -610,6 +612,8 @@ pub(crate) enum Task { bool, AsyncContextSnapshot, *mut crate::r#box::AsyncBoxActivation, + u64, + u64, ), } diff --git a/crates/perry-runtime/src/promise/scanners.rs b/crates/perry-runtime/src/promise/scanners.rs index dd27803d03..f778cc4e82 100644 --- a/crates/perry-runtime/src/promise/scanners.rs +++ b/crates/perry-runtime/src/promise/scanners.rs @@ -41,7 +41,7 @@ pub fn scan_promise_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { visitor.visit_raw_const_ptr_slot(callback); scan_snapshot_roots_mut(context, visitor); } - Task::AsyncStep(cb, value, next, _, context, _) => { + Task::AsyncStep(cb, value, next, _, context, _, _, _) => { visitor.visit_raw_const_ptr_slot(cb); visitor.visit_raw_mut_ptr_slot(next); visitor.visit_nanbox_f64_slot(value); @@ -265,7 +265,7 @@ fn scan_task_step( scan_task_slot_promise_all(promise_state, value, context, visitor, state, remaining) } Task::Inline(cb, value, next, _, context) - | Task::AsyncStep(cb, value, next, _, context, _) => { + | Task::AsyncStep(cb, value, next, _, context, _, _, _) => { scan_task_slot_inline(cb, value, next, context, visitor, state, remaining) } Task::Microtask { @@ -770,6 +770,8 @@ pub(crate) fn test_seed_promise_scanner_roots( false, context.clone(), std::ptr::null_mut(), + 0, + 0, )); }); PROMISE_CONTEXTS.with(|contexts| { @@ -854,7 +856,7 @@ pub(crate) fn test_promise_scanner_snapshot() -> TestPromiseScannerSnapshot { snapshot.inline_next_ptr = *next_ptr as usize; snapshot.inline_value_bits = value.to_bits(); } - if let Some(Task::AsyncStep(callback_ptr, value, next_ptr, _, _, _)) = q.get(2) { + if let Some(Task::AsyncStep(callback_ptr, value, next_ptr, _, _, _, _, _)) = q.get(2) { snapshot.async_step_callback_ptr = *callback_ptr as usize; snapshot.async_step_next_ptr = *next_ptr as usize; snapshot.async_step_value_bits = value.to_bits(); diff --git a/crates/perry-runtime/src/promise/then.rs b/crates/perry-runtime/src/promise/then.rs index ba5c7fa6f0..9f9eb55cb2 100644 --- a/crates/perry-runtime/src/promise/then.rs +++ b/crates/perry-runtime/src/promise/then.rs @@ -69,6 +69,12 @@ fn js_promise_new_with_parent_impl(parent: *mut Promise, force_malloc: bool) -> bump(&MT_PROMISE_NEW_COUNT); let async_hooks_active = crate::async_hooks::promise_hooks_active(); let lifecycle_hooks_active = async_hooks_active || crate::v8::promise_hooks_active(); + // Root the parent before allocating the child. The allocation itself may + // move an arena-resident Promise that predates hook enablement; rooting it + // afterwards records the retired address and gives the child a stale + // parent/trigger id. + let scope = crate::gc::RuntimeHandleScope::new(); + let parent_handle = scope.root_raw_mut_ptr(parent); let raw = if lifecycle_hooks_active || force_malloc { crate::gc::gc_malloc(std::mem::size_of::(), crate::gc::GC_TYPE_PROMISE) } else { @@ -79,30 +85,57 @@ fn js_promise_new_with_parent_impl(parent: *mut Promise, force_malloc: bool) -> ) }; let promise = raw as *mut Promise; - let scope = crate::gc::RuntimeHandleScope::new(); let promise_handle = scope.root_raw_mut_ptr(promise); - let parent_handle = scope.root_raw_mut_ptr(parent); unsafe { // GC_STORE_AUDIT(INIT): initializes freshly allocated Promise storage before the promise is published. ptr::write(promise, Promise::new()); + let trigger_async_id = parent_handle.with_mut_ptr::(|parent| { + if parent.is_null() { + crate::async_hooks::execution_async_id_u64() + } else { + (*parent).async_id + } + }); if async_hooks_active { - let promise = promise_handle.get_raw_mut_ptr::(); - let resource = - f64::from_bits(0x7FFD_0000_0000_0000 | (promise as u64 & 0x0000_FFFF_FFFF_FFFF)); - let ids = crate::async_hooks::init_resource("PROMISE", resource, false); - let promise = promise_handle.get_raw_mut_ptr::(); - (*promise).async_id = ids.async_id; - (*promise).trigger_async_id = ids.trigger_async_id; + let ids = promise_handle.with_mut_ptr::(|promise| { + let resource = f64::from_bits( + 0x7FFD_0000_0000_0000 | (promise as u64 & 0x0000_FFFF_FFFF_FFFF), + ); + crate::async_hooks::init_resource_with_trigger( + "PROMISE", + resource, + false, + trigger_async_id, + ) + }); + promise_handle.with_mut_ptr::(|promise| { + (*promise).async_id = ids.async_id; + (*promise).trigger_async_id = ids.trigger_async_id; + }); + } else { + // Node assigns every Promise an id, not only promises created + // while an observer is enabled. Keep this reservation out of the + // resource table so an unobserved promise is not made immortal by + // a strong resource root. + let ids = crate::async_hooks::reserve_resource_ids(trigger_async_id); + promise_handle.with_mut_ptr::(|promise| { + (*promise).async_id = ids.async_id; + (*promise).trigger_async_id = ids.trigger_async_id; + }); } } - crate::v8::promise_hook_init( - promise_handle.get_raw_mut_ptr::(), - parent_handle.get_raw_mut_ptr::(), - ); - let promise = promise_handle.get_raw_mut_ptr::(); - // #5142: a recycled address may carry expando properties (`p.status = …`) - // left by a previously-collected promise; a fresh promise must start clean. - crate::object::exotic_expando::expando_clear_on_alloc(promise as usize); + promise_handle.with_mut_ptr::(|promise| { + parent_handle.with_mut_ptr::(|parent| { + crate::v8::promise_hook_init(promise, parent); + }); + }); + let (_, promise) = promise_handle.across_mut::(|| { + promise_handle.with_mut_ptr::(|promise| { + // #5142: a recycled address may carry expando properties (`p.status = …`) + // left by a previously-collected promise; a fresh promise must start clean. + crate::object::exotic_expando::expando_clear_on_alloc(promise as usize); + }); + }); promise } @@ -242,9 +275,6 @@ pub extern "C" fn js_promise_resolve(promise: *mut Promise, value: f64) { // 1 s idle cap before the loop re-checks promise state. The notify // sets the flag so the immediately-following wait returns at once. crate::event_pump::js_notify_promise_progress(); - unsafe { - crate::async_hooks::destroy_promise((*promise).async_id); - } } /// Resolve a promise with another promise (Promise chaining/unwrapping) @@ -461,9 +491,6 @@ pub extern "C" fn js_promise_reject(promise: *mut Promise, reason: f64) { } // Issue #84: see js_promise_resolve — same wake reasoning. crate::event_pump::js_notify_promise_progress(); - unsafe { - crate::async_hooks::destroy_promise((*promise).async_id); - } } /// Register fulfillment callback, returns a new promise for chaining @@ -1732,14 +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(), + async_id, + trigger_async_id, )); }); crate::event_pump::js_notify_promise_progress(); @@ -1758,14 +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(), + 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 996a23cc8a..db09ed5520 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1069,9 +1069,45 @@ fn small_handle_from_value(value: f64) -> Option { None } +/// Native `AsyncResource` values are process-stable `Box` pointers rather +/// than GC `ObjectHeader`s or ids in the small-handle band. Recognize their +/// exact registry membership before any generic object walk can interpret the +/// allocation (or its allocator prefix) as GC/object metadata. +fn async_resource_handle_from_value(value: f64) -> Option { + let bits = value.to_bits(); + let raw = match bits >> 48 { + top if top == (POINTER_TAG >> 48) => (bits & POINTER_MASK) as i64, + 0 => bits as i64, + _ => return None, + }; + crate::async_hooks::is_async_resource_handle(raw).then_some(raw) +} + fn set_handle_property(target: f64, key: f64, value: f64) -> Option { - let handle = small_handle_from_value(target)?; - let Some(name) = key_to_rust_string(key) else { + 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.get_nanbox_f64()) else { + return Some(false); + }; + crate::object::handle_expando::handle_expando_set(handle, &name, value.get_nanbox_f64()); + return Some(true); + } + + 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 @@ -1080,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) } @@ -1403,8 +1445,11 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { } if unsafe { crate::symbol::js_is_symbol(key) } != 0 { - let value = unsafe { crate::symbol::js_object_get_symbol_property(target, key) }; - if value.to_bits() == TAG_UNDEFINED { + // `undefined` is a valid value for an existing own property. Using a + // value read as the existence probe made the first overwrite of the + // symbol metadata installed by an async_hooks `init` callback take the + // absent-property path and disappear on native AsyncResource handles. + if !unsafe { crate::symbol::has_own_symbol_property(target, key) } { return None; } // An existing symbol-keyed own data property is non-writable when the diff --git a/crates/perry-runtime/src/proxy/reflect.rs b/crates/perry-runtime/src/proxy/reflect.rs index d91cfc26d2..8833a56912 100644 --- a/crates/perry-runtime/src/proxy/reflect.rs +++ b/crates/perry-runtime/src/proxy/reflect.rs @@ -109,6 +109,21 @@ pub extern "C" fn js_reflect_set(target: f64, key: f64, value: f64, receiver: f6 if lookup(target).is_some() { return super::proxy_set_with_receiver(target, property_key, value, receiver); } + // ES-module namespace imports use the namespace exotic [[Set]] operation: + // their reflected data descriptors report writable=true, but writes still + // fail. `node:async_hooks` is materialized through Perry's shared native + // namespace object, so preserve that otherwise-unusual combination here. + let target_ptr = extract_pointer(target.to_bits()); + if target_ptr != 0 { + let obj = target_ptr as *const crate::object::ObjectHeader; + if crate::value::addr_class::is_plausible_heap_addr(target_ptr as usize) + && unsafe { (*obj).class_id } == crate::object::NATIVE_MODULE_CLASS_ID + && unsafe { crate::object::read_native_module_name(obj) }.as_deref() + == Some("async_hooks") + { + return nanbox_bool(false); + } + } reflect_ordinary_set_with_receiver(target, property_key, value, receiver) } diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index dffcb0ddd2..8edf6e0d4d 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -338,6 +338,14 @@ pub extern "C" fn js_symbol_well_known_iterator() -> f64 { f64::from_bits(POINTER_TAG | (symbol as u64 & POINTER_MASK)) } +/// Provider-safe C ABI for separately linked native extensions that need to +/// expose a genuine async iterable. +#[no_mangle] +pub extern "C" fn js_symbol_well_known_async_iterator() -> f64 { + let symbol = well_known_symbol("asyncIterator"); + f64::from_bits(POINTER_TAG | (symbol as u64 & POINTER_MASK)) +} + /// O(1) check whether a raw pointer is a well-known symbol (Symbol.toPrimitive etc.). /// Used by `js_symbol_key_for` so the spec-mandated `undefined` return for /// well-known symbols is preserved. diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 83b79c635d..44dd490707 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -802,7 +802,10 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 let tag_f64 = f64::from_bits(crate::value::JSValue::pointer(tag_wk as *const u8).bits()); if sym_key_from_f64(sym_f64) == sym_key_from_f64(tag_f64) - && crate::object::read_native_module_name(obj).as_deref() == Some("module") + && matches!( + crate::object::read_native_module_name(obj).as_deref(), + Some("module" | "async_hooks") + ) { let tag = b"Module"; let value = js_string_from_bytes(tag.as_ptr(), tag.len() as u32); diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index b2dbfac3b1..25417651c0 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -335,6 +335,14 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 if obj_key == 0 || sym_key == 0 { return value_f64; } + // A base AsyncResource's public identity is a process-stable Box pointer, + // registered separately from Perry's moving GC heap. It intentionally + // uses POINTER_TAG so it behaves as an object, but bytes before/inside the + // Box are allocator state and AsyncResource ids, not GcHeader/ObjectHeader + // fields. Never use those bytes for frozen/extensible or class-setter + // decisions; symbol values for the handle live entirely in this side + // table. + let native_async_resource = crate::async_hooks::is_async_resource_handle(obj_key as i64); super::note_symbol_key_installed(sym_key); // #5437 (Next.js): a native HANDLE (small-id NaN-boxed POINTER, e.g. the // node:http IncomingMessage) carries per-request metadata in the symbol @@ -384,7 +392,8 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 // like string-keyed ones: an existing prop is non-writable when frozen // (or its per-symbol attrs say so), a new prop is forbidden when // non-extensible. Only heap receivers carry the GC flag word. - if (obj_f64.to_bits() >> 48) == 0x7FFD + if !native_async_resource + && (obj_f64.to_bits() >> 48) == 0x7FFD && obj_key >= 0x10000 && crate::object::is_valid_obj_ptr(obj_key as *const u8) { @@ -411,7 +420,7 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 { return value_f64; } - } else { + } else if !native_async_resource { let jsval = crate::value::JSValue::from_bits(bits); if jsval.is_pointer() { let ptr = jsval.as_pointer::(); diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index 1a45fe9c1a..c703489592 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -12,10 +12,11 @@ mod async_lifecycle; use crate::promise::{js_promise_new, js_promise_resolve, Promise}; use async_lifecycle::{enqueue_destroy_ids, IntervalCallback}; use std::any::Any; +use std::collections::HashMap; use std::os::raw::c_int; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Mutex, + LazyLock, Mutex, }; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -419,6 +420,8 @@ pub(crate) use gc_scan::{new_timer_root_scan_state, scan_timer_roots_mut_step}; use ref_states::{TimerRefStates, TIMER_REF_STATES_CAP}; static TIMER_REF_STATES: Mutex> = Mutex::new(None); +static TIMER_HANDLE_KINDS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); static WARNED_NEGATIVE_TIMER_DELAY: AtomicBool = AtomicBool::new(false); static WARNED_NAN_TIMER_DELAY: AtomicBool = AtomicBool::new(false); @@ -598,6 +601,49 @@ fn set_timer_ref_state(id: i64, has_ref: bool) { .insert_bounded(id, has_ref, TIMER_REF_STATES_CAP); } +fn record_timer_handle_kind(id: i64, kind: CallbackTimerKind) { + 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 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 { + CallbackTimerKind::Timeout => b"Timeout".as_slice(), + CallbackTimerKind::Immediate => b"Immediate".as_slice(), + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(crate::object::js_object_alloc_null_proto(0, 0)); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes(b"name".as_ptr(), 4)); + let value = scope.root_string_ptr(crate::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )); + let (_, obj_ptr) = obj.across_mut::(|| { + obj.with_mut_ptr::(|obj_ptr| { + key.with_mut_ptr::(|key_ptr| { + value.with_mut_ptr::(|value_ptr| { + crate::object::js_object_set_field_by_name( + obj_ptr, + key_ptr, + f64::from_bits(crate::value::JSValue::string_ptr(value_ptr).bits()), + ); + }); + }); + }); + }); + Some(crate::value::js_nanbox_pointer(obj_ptr as i64)) +} + pub use ref_states::is_known_timer_id; fn throw_mock_timer_invalid_state(message: &str) -> ! { @@ -705,6 +751,7 @@ fn schedule_mock_callback_timer( let arg_handles = scope.root_nanbox_f64_slice(&args); let delay = normalize_timer_delay(delay_ms); let id = next_timer_id(); + record_timer_handle_kind(id, kind); let due_ms = state.current_ms + delay as f64; state.callbacks.push(MockCallbackTimer { id, @@ -730,6 +777,7 @@ fn schedule_mock_interval_timer(callback: i64, interval_ms: f64, args: Vec) let arg_handles = scope.root_nanbox_f64_slice(&args); let interval = normalize_timer_delay(interval_ms); let id = next_timer_id(); + record_timer_handle_kind(id, CallbackTimerKind::Timeout); let next_ms = state.current_ms + interval as f64; state.intervals.push(MockIntervalTimer { id, @@ -988,6 +1036,7 @@ pub extern "C" fn js_set_timeout_callback(callback: i64, delay_ms: f64) -> i64 { Vec::new(), "Timeout", CallbackTimerKind::Timeout, + None, ) } @@ -999,6 +1048,7 @@ pub extern "C" fn js_set_immediate_callback(callback: i64) -> i64 { Vec::new(), "Immediate", CallbackTimerKind::Immediate, + None, ) } @@ -1008,6 +1058,7 @@ fn schedule_callback_timer( args: Vec, type_name: &str, kind: CallbackTimerKind, + trigger_async_id: Option, ) -> i64 { crate::promise::bump(&PROFILE_CALLBACK_TIMER_REGISTRATIONS); if let Some(id) = schedule_mock_callback_timer(callback, delay_ms, args.clone(), kind) { @@ -1023,12 +1074,22 @@ fn schedule_callback_timer( let deadline = Instant::now() + Duration::from_millis(delay_ms); let id = next_timer_id(); + record_timer_handle_kind(id, kind); let mut context = crate::async_context::capture_context(); let context_roots = crate::async_context::root_snapshot(&scope, &context); - let (ids, callback) = callback_handle.across_const::(|| { - crate::async_hooks::init_resource(type_name, timer_handle_value(id), true) - }); + let (ids, callback) = + callback_handle.across_const::( + || match trigger_async_id { + Some(trigger_async_id) => crate::async_hooks::init_resource_with_trigger( + type_name, + timer_handle_value(id), + true, + trigger_async_id, + ), + None => crate::async_hooks::init_resource(type_name, timer_handle_value(id), true), + }, + ); crate::async_context::refresh_snapshot_from_roots(&mut context, &context_roots); CALLBACK_TIMERS.lock().unwrap().push(CallbackTimer { @@ -1078,6 +1139,7 @@ pub unsafe extern "C" fn js_set_timeout_callback_args( args, "Timeout", CallbackTimerKind::Timeout, + None, ) } @@ -1098,6 +1160,58 @@ pub unsafe extern "C" fn js_set_immediate_callback_args( args, "Immediate", CallbackTimerKind::Immediate, + None, + ) +} + +/// Schedule a native Node-style completion callback as its own async-hooks +/// provider. Native stdlib operations use the ordinary immediate queue for +/// deferred delivery, but must expose their actual provider name (for example +/// `PBKDF2REQUEST`) and execute with that provider's async id/resource rather +/// than masquerading as an `Immediate`. +pub fn schedule_native_callback(callback: i64, args: &[f64], provider_type: &'static str) -> i64 { + schedule_callback_timer( + callback, + 0.0, + args.to_vec(), + provider_type, + CallbackTimerKind::Immediate, + None, + ) +} + +/// Schedule the final callback in a provider chain while emitting the eager +/// native preparation stages ahead of it. Node's `fs.readFile` is implemented +/// as four chained FSREQCALLBACK operations (open, stat, read, close); Perry +/// performs those syscalls eagerly, but the observable hook graph must retain +/// the same four-resource ancestry. +pub fn schedule_native_callback_chain( + callback: i64, + args: &[f64], + provider_type: &'static str, + resource_count: usize, +) -> i64 { + let mut trigger = crate::async_hooks::execution_async_id_u64(); + for _ in 1..resource_count { + let resource = crate::object::js_object_alloc_null_proto(0, 0); + let ids = crate::async_hooks::init_resource_with_trigger( + provider_type, + crate::value::js_nanbox_pointer(resource as i64), + true, + trigger, + ); + crate::async_hooks::before(ids.async_id, ids.trigger_async_id); + crate::async_hooks::after(ids.async_id); + crate::async_hooks::destroy(ids.async_id); + trigger = ids.async_id; + } + schedule_callback_timer( + callback, + 0.0, + args.to_vec(), + provider_type, + CallbackTimerKind::Immediate, + Some(trigger), ) } @@ -1475,6 +1589,7 @@ fn schedule_interval_timer(callback: i64, interval_ms: f64, args: Vec) -> i let next_deadline = Instant::now() + Duration::from_millis(interval); let id = next_timer_id(); + record_timer_handle_kind(id, CallbackTimerKind::Timeout); let mut context = crate::async_context::capture_context(); let context_roots = crate::async_context::root_snapshot(&scope, &context); diff --git a/crates/perry-runtime/src/value/addr_class.rs b/crates/perry-runtime/src/value/addr_class.rs index 5295905933..41c3397ed8 100644 --- a/crates/perry-runtime/src/value/addr_class.rs +++ b/crates/perry-runtime/src/value/addr_class.rs @@ -163,7 +163,7 @@ pub fn is_stream_id_band(id: usize) -> bool { /// `test_gap_proxy_reflect` on Linux. Those dependents must be migrated to an /// explicit band check FIRST; only then can this floor be raised. #[inline(always)] -pub(crate) fn is_valid_obj_ptr(ptr: *const u8) -> bool { +pub fn is_valid_obj_ptr(ptr: *const u8) -> bool { let addr = ptr as u64; #[cfg(any( target_os = "android", diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 0a7e614f98..56655eb781 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -218,6 +218,19 @@ tls-runtime = [ "dep:x509-cert", ] +# Retains the TLS module/server and TLSSocket dispatch surface when `node:tls` +# is routed to perry-ext-net. Unlike `tls`, this must not pull bundled-net back +# in because the wrapper owns client sockets and their event pump. +external-tls-server = [ + "async-runtime", + "dep:tokio-rustls", + "dep:rustls", + "dep:rustls-native-certs", + "dep:rustls-pemfile", + "dep:base64", + "dep:x509-cert", +] + # Databases database = ["database-postgres", "database-mysql", "database-sqlite", "database-redis", "database-mongodb"] # `database-postgres` umbrella retained for backwards-compat; diff --git a/crates/perry-stdlib/src/async_local_storage.rs b/crates/perry-stdlib/src/async_local_storage.rs index 4ab3b9c8bb..5fc45afd9e 100644 --- a/crates/perry-stdlib/src/async_local_storage.rs +++ b/crates/perry-stdlib/src/async_local_storage.rs @@ -8,10 +8,9 @@ use perry_runtime::closure::{is_closure_ptr, js_closure_call_array, ClosureHeade use crate::common::{get_handle_mut, register_handle, Handle}; -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +const SUBCLASS_BACKING_KEY: &[u8] = b"__perryAsyncLocalStorageBacking"; // Keep the active context in the single perry-runtime provider. These must be // extern calls rather than Rust-path calls: in app-only dylib deployments the @@ -42,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)) } @@ -84,6 +85,95 @@ 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 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)) +} + +pub(crate) fn resolve_async_local_storage_handle(receiver: Handle) -> Option { + if get_handle_mut::(receiver).is_some() { + return Some(receiver); + } + let raw = receiver as usize; + if !perry_runtime::value::addr_class::is_above_handle_band(raw) + || !perry_runtime::value::addr_class::is_valid_obj_ptr(raw as *const u8) + { + return None; + } + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let receiver = scope.root_raw_mut_ptr(raw as *mut perry_runtime::object::ObjectHeader); + 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 value = receiver.with_mut_ptr(|receiver| { + key.with_mut_ptr(|key| { + perry_runtime::object::js_object_get_field_by_name_f64(receiver, key) + }) + }); + if value.to_bits() >> 48 != 0x7FFD { + return None; + } + let backing = (value.to_bits() & POINTER_MASK) as Handle; + get_handle_mut::(backing).map(|_| backing) +} + +/// Stamp an ordinary source-compiled subclass instance with a native ALS +/// backing. Its inherited methods receive the ordinary object as `this` and +/// resolve through the hidden handle above. +#[no_mangle] +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 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 = 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.get_raw_mut_ptr(), + backing_value.get_nanbox_f64(), + ); + for method in [ + b"run".as_slice(), + b"getStore".as_slice(), + b"enterWith".as_slice(), + b"exit".as_slice(), + b"disable".as_slice(), + ] { + let value = crate::common::dispatch::unbound_async_local_storage_method(method); + let value_handle = scope.root_nanbox_f64(value); + 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.get_raw_mut_ptr(), + value_handle.get_nanbox_f64(), + ); + } + } + this_handle.get_nanbox_f64() +} + /// Create a new AsyncLocalStorage instance /// Returns a handle (i64) #[no_mangle] @@ -97,20 +187,29 @@ pub extern "C" fn js_async_local_storage_new() -> Handle { /// codegen `NA_VARARGS` lowering (#3093). #[no_mangle] pub unsafe extern "C" fn js_async_local_storage_run( - handle: Handle, + receiver: Handle, store: f64, 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 @@ -119,19 +218,22 @@ pub unsafe extern "C" fn js_async_local_storage_run( /// AsyncLocalStorage.getStore() /// Returns the current store (top of stack) or undefined #[no_mangle] -pub extern "C" fn js_async_local_storage_get_store(handle: Handle) -> f64 { - if get_handle_mut::(handle).is_some() { - return unsafe { js_async_context_als_get_store(handle) }; - } - f64::from_bits(TAG_UNDEFINED) +pub extern "C" fn js_async_local_storage_get_store(receiver: Handle) -> f64 { + let handle = + resolve_async_local_storage_handle(receiver).unwrap_or_else(|| throw_invalid_receiver()); + unsafe { js_async_context_als_get_store(handle) } } /// AsyncLocalStorage.enterWith(store) /// Push store onto stack (caller is responsible for cleanup) #[no_mangle] -pub extern "C" fn js_async_local_storage_enter_with(handle: Handle, store: f64) { - if get_handle_mut::(handle).is_some() { - unsafe { js_async_context_als_enter_with(handle, store) }; +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.get_nanbox_f64()) }; } } @@ -141,24 +243,26 @@ pub extern "C" fn js_async_local_storage_enter_with(handle: Handle, store: f64) /// `NA_VARARGS` lowering (#3093). #[no_mangle] pub unsafe extern "C" fn js_async_local_storage_exit( - handle: Handle, + receiver: Handle, 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 guarded = get_handle_mut::(handle).is_some(); - if guarded { - js_async_context_als_exit_enter(handle); - } + 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); - if guarded { - js_async_context_als_scope_leave(); - } + js_async_context_als_scope_leave(); result } @@ -166,8 +270,8 @@ pub unsafe extern "C" fn js_async_local_storage_exit( /// AsyncLocalStorage.disable() /// Clear the store stack #[no_mangle] -pub extern "C" fn js_async_local_storage_disable(handle: Handle) { - if get_handle_mut::(handle).is_some() { +pub extern "C" fn js_async_local_storage_disable(receiver: Handle) { + if let Some(handle) = resolve_async_local_storage_handle(receiver) { unsafe { js_async_context_als_clear(handle) }; } } diff --git a/crates/perry-stdlib/src/common/dispatch.rs b/crates/perry-stdlib/src/common/dispatch.rs index 800e1ace1e..97abfc2795 100644 --- a/crates/perry-stdlib/src/common/dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch.rs @@ -26,6 +26,7 @@ pub use property_dispatch::js_handle_property_dispatch; pub(crate) use emitter_als::{ dispatch_async_local_storage_method, dispatch_async_local_storage_property, + unbound_async_local_storage_method, }; #[cfg(any(feature = "bundled-events", feature = "external-events-construct"))] pub(crate) use emitter_als::{dispatch_event_emitter_method, dispatch_event_emitter_property}; diff --git a/crates/perry-stdlib/src/common/dispatch/emitter_als.rs b/crates/perry-stdlib/src/common/dispatch/emitter_als.rs index 0685887b2e..4a324364cd 100644 --- a/crates/perry-stdlib/src/common/dispatch/emitter_als.rs +++ b/crates/perry-stdlib/src/common/dispatch/emitter_als.rs @@ -1,4 +1,3 @@ -use super::super::handle::*; use super::*; /// `js_class_method_bind` retains the name pointer in the closure, so make a @@ -47,6 +46,97 @@ fn async_local_storage_method_name_static(property: &str) -> Option<&'static [u8 } } +extern "C" fn async_local_storage_unbound_method_thunk( + closure: *const perry_runtime::closure::ClosureHeader, + rest: f64, +) -> f64 { + unsafe { + let name_ptr = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as *const i8; + 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 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 { + 0 + }; + + // Node deliberately makes these two operations harmless when their + // method value is invoked without an ALS receiver. + if matches!(name, b"enterWith" | b"disable") + && crate::async_local_storage::resolve_async_local_storage_handle(receiver_raw) + .is_none() + { + return TAG_UNDEFINED_F64; + } + + 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 { + let len = perry_runtime::array::js_array_length(args_array) as usize; + (0..len) + .map(|index| { + f64::from_bits( + perry_runtime::array::js_array_get(args_array, index as u32).bits(), + ) + }) + .collect::>() + }; + 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. + match name { + b"getStore" => { + crate::async_local_storage::js_async_local_storage_get_store(receiver_raw) + } + b"run" => crate::async_local_storage::js_async_local_storage_run( + receiver_raw, + args.first().copied().unwrap_or(TAG_UNDEFINED_F64), + args.get(1).copied().unwrap_or(TAG_UNDEFINED_F64), + 0, + ), + b"exit" => crate::async_local_storage::js_async_local_storage_exit( + receiver_raw, + args.first().copied().unwrap_or(TAG_UNDEFINED_F64), + 0, + ), + _ => TAG_UNDEFINED_F64, + } + } +} + +pub(crate) fn unbound_async_local_storage_method(method: &'static [u8]) -> f64 { + perry_runtime::closure::js_register_closure_rest( + async_local_storage_unbound_method_thunk as *const u8, + 0, + ); + let closure = perry_runtime::closure::js_closure_alloc( + async_local_storage_unbound_method_thunk as *const u8, + 2, + ); + if closure.is_null() { + return TAG_UNDEFINED_F64; + } + perry_runtime::closure::js_closure_set_capture_ptr(closure, 0, method.as_ptr() as i64); + perry_runtime::closure::js_closure_set_capture_ptr(closure, 1, method.len() as i64); + perry_runtime::value::js_nanbox_pointer(closure as i64) +} + /// Dynamic dispatch for `AsyncLocalStorage` receivers whose static type the /// codegen lost (`any`-typed bindings, closure captures). Gated on registry /// type membership so no other subsystem's handle is claimed (#788). @@ -61,9 +151,10 @@ pub(crate) unsafe fn dispatch_async_local_storage_method( ) { return None; } - if get_handle_mut::(handle).is_none() { - 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 => { @@ -73,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, ) @@ -89,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" => { @@ -119,31 +214,21 @@ pub(crate) unsafe fn dispatch_event_emitter_method( f64::from_bits(POINTER_TAG_BITS | (ptr as u64 & POINTER_MASK_BITS)) }; - // EventEmitterAsyncResource extras exist only in the bundled impl; - // perry-ext-events has no async-resource constructor, so its handles - // never satisfy this probe. - #[cfg(feature = "bundled-events")] - if crate::events::is_event_emitter_async_resource_handle(handle) { - match method { - "asyncId" => { - return Some(crate::events::js_event_emitter_async_resource_async_id( - handle, - )); - } - "triggerAsyncId" => { - return Some( - crate::events::js_event_emitter_async_resource_trigger_async_id(handle), - ); - } - "asyncResource" => { - return Some(crate::events::js_event_emitter_async_resource_async_resource(handle)); - } - "emitDestroy" => { - return Some(crate::events::js_event_emitter_async_resource_emit_destroy( - handle, - )); - } - _ => {} + if perry_runtime::object::event_emitter_async_resource_handle_probe() + .is_some_and(|probe| probe(handle)) + { + let operation = match method { + "asyncId" => Some(0), + "triggerAsyncId" => Some(1), + "asyncResource" => Some(2), + "emitDestroy" => Some(3), + _ => None, + }; + if let (Some(operation), Some(dispatch)) = ( + operation, + perry_runtime::object::event_emitter_async_resource_dispatch(), + ) { + return Some(dispatch(handle, operation)); } } @@ -208,24 +293,23 @@ pub(crate) unsafe fn dispatch_event_emitter_property(handle: i64, property: &str return None; } - #[cfg(feature = "bundled-events")] - if crate::events::is_event_emitter_async_resource_handle(handle) { - match property { - "asyncId" => { - return Some(crate::events::js_event_emitter_async_resource_async_id( - handle, - )); - } - "triggerAsyncId" => { - return Some( - crate::events::js_event_emitter_async_resource_trigger_async_id(handle), - ); - } - "asyncResource" => { - return Some(crate::events::js_event_emitter_async_resource_async_resource(handle)); - } - "emitDestroy" => return Some(bind_static_handle_method(handle, b"emitDestroy")), - _ => {} + if perry_runtime::object::event_emitter_async_resource_handle_probe() + .is_some_and(|probe| probe(handle)) + { + if property == "emitDestroy" { + return Some(bind_static_handle_method(handle, b"emitDestroy")); + } + let operation = match property { + "asyncId" => Some(0), + "triggerAsyncId" => Some(1), + "asyncResource" => Some(2), + _ => None, + }; + if let (Some(operation), Some(dispatch)) = ( + operation, + perry_runtime::object::event_emitter_async_resource_dispatch(), + ) { + return Some(dispatch(handle, operation)); } } @@ -249,10 +333,8 @@ pub(crate) unsafe fn dispatch_async_local_storage_property( property: &str, ) -> Option { let method = async_local_storage_method_name_static(property)?; - if get_handle_mut::(handle).is_none() { - return None; - } - Some(bind_static_handle_method(handle, method)) + crate::async_local_storage::resolve_async_local_storage_handle(handle)?; + Some(unbound_async_local_storage_method(method)) } #[cfg(test)] diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 0ecf9253ef..1cf933d6e4 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -454,6 +454,9 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { fn js_register_event_emitter_async_resource_handle_probe( f: unsafe extern "C" fn(i64) -> bool, ); + fn js_register_event_emitter_async_resource_dispatch( + f: unsafe extern "C" fn(i64, u32) -> f64, + ); fn js_register_event_emitter_on(f: EventEmitterOn); #[cfg(feature = "web-fetch")] fn js_register_global_fetch_with_options( @@ -577,6 +580,18 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { } #[cfg(feature = "bundled-events")] js_register_event_emitter_async_resource_handle_probe(event_emitter_async_resource_probe); + #[cfg(feature = "bundled-events")] + unsafe extern "C" fn event_emitter_async_resource_dispatch(handle: i64, operation: u32) -> f64 { + match operation { + 0 => crate::events::js_event_emitter_async_resource_async_id(handle), + 1 => crate::events::js_event_emitter_async_resource_trigger_async_id(handle), + 2 => crate::events::js_event_emitter_async_resource_async_resource(handle), + 3 => crate::events::js_event_emitter_async_resource_emit_destroy(handle), + _ => TAG_UNDEFINED_F64, + } + } + #[cfg(feature = "bundled-events")] + js_register_event_emitter_async_resource_dispatch(event_emitter_async_resource_dispatch); #[cfg(any(feature = "bundled-events", feature = "external-events-construct"))] unsafe extern "C" fn event_emitter_on_hook( handle: i64, diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index 3f9c4cd092..53d7ae7ef8 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -1,4 +1,5 @@ -use super::super::handle::*; +#[cfg(any(feature = "crypto", feature = "database-redis"))] +use super::super::handle::with_handle; use super::*; /// Route external zlib stream methods before the generic dispatcher creates @@ -115,7 +116,7 @@ unsafe fn try_dispatch_external_http_client( }; if !matches!( method_name, - "setEncoding" | "on" | "addListener" | "pipe" | "pause" | "resume" + "setEncoding" | "on" | "once" | "addListener" | "pipe" | "pause" | "resume" ) { return None; } diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index 4885ea9aad..99758d79cd 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -1,4 +1,5 @@ -use super::super::handle::*; +#[cfg(any(feature = "crypto", feature = "http-client"))] +use super::super::handle::with_handle; use super::*; /// Dispatch a property access on a handle-based object. @@ -27,6 +28,12 @@ pub unsafe extern "C" fn js_handle_property_dispatch( return v; } + if let Some(value) = + perry_runtime::async_hooks::try_async_resource_property_dispatch(handle, property_name) + { + return value; + } + #[cfg(any(feature = "bundled-events", feature = "external-events-construct"))] if let Some(value) = dispatch_event_emitter_property(handle, property_name) { return value; diff --git a/crates/perry-stdlib/src/common/dispatch_http.rs b/crates/perry-stdlib/src/common/dispatch_http.rs index 50ed08efa1..aae66413fd 100644 --- a/crates/perry-stdlib/src/common/dispatch_http.rs +++ b/crates/perry-stdlib/src/common/dispatch_http.rs @@ -145,7 +145,10 @@ pub(super) unsafe fn dispatch_client_incoming_method( method_name: &str, args: &[f64], ) -> Option { - if !matches!(method_name, "setEncoding" | "on" | "addListener" | "pipe") { + if !matches!( + method_name, + "setEncoding" | "on" | "once" | "addListener" | "pipe" + ) { return None; } @@ -160,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; } @@ -184,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/crypto/kdf.rs b/crates/perry-stdlib/src/crypto/kdf.rs index 38390c41a2..9194ff7788 100644 --- a/crates/perry-stdlib/src/crypto/kdf.rs +++ b/crates/perry-stdlib/src/crypto/kdf.rs @@ -64,7 +64,12 @@ pub unsafe extern "C" fn js_crypto_pbkdf2_async_alg( } else { f64::from_bits(JSValue::pointer(buf as *const u8).bits()) }; - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value); + schedule_node_style_callback2( + callback_bits, + f64::from_bits(JSValue::null().bits()), + value, + "PBKDF2REQUEST", + ); f64::from_bits(JSValue::undefined().bits()) } @@ -130,7 +135,12 @@ pub unsafe extern "C" fn js_crypto_hkdf_async_alg( } else { f64::from_bits(JSValue::pointer(buf as *const u8).bits()) }; - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value); + schedule_node_style_callback2( + callback_bits, + f64::from_bits(JSValue::null().bits()), + value, + "DERIVEBITSREQUEST", + ); f64::from_bits(JSValue::undefined().bits()) } @@ -149,7 +159,12 @@ pub unsafe extern "C" fn js_crypto_scrypt_async( } else { f64::from_bits(JSValue::pointer(buf as *const u8).bits()) }; - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value); + schedule_node_style_callback2( + callback_bits, + f64::from_bits(JSValue::null().bits()), + value, + "SCRYPTREQUEST", + ); f64::from_bits(JSValue::undefined().bits()) } @@ -324,7 +339,12 @@ pub unsafe extern "C" fn js_crypto_argon2_async( } else { f64::from_bits(JSValue::pointer(buf as *const u8).bits()) }; - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value); + schedule_node_style_callback2( + callback_bits, + f64::from_bits(JSValue::null().bits()), + value, + "ARGON2REQUEST", + ); f64::from_bits(JSValue::undefined().bits()) } diff --git a/crates/perry-stdlib/src/crypto/keys.rs b/crates/perry-stdlib/src/crypto/keys.rs index 211bc6846c..8ffa4087e0 100644 --- a/crates/perry-stdlib/src/crypto/keys.rs +++ b/crates/perry-stdlib/src/crypto/keys.rs @@ -102,12 +102,31 @@ pub(super) unsafe fn call_node_style_callback2(callback_bits: f64, err: f64, val ); } -pub(super) unsafe fn call_node_style_callback3(callback_bits: f64, err: f64, a: f64, b: f64) { +pub(super) unsafe fn schedule_node_style_callback2( + callback_bits: f64, + err: f64, + value: f64, + provider_type: &'static str, +) { let raw = callback_bits.to_bits() & 0x0000_FFFF_FFFF_FFFF; - if raw < 0x1000 { + if !perry_runtime::closure::is_closure_ptr(raw as usize) { return; } - perry_runtime::closure::js_closure_call3(raw as *const perry_runtime::ClosureHeader, err, a, b); + perry_runtime::timer::schedule_native_callback(raw as i64, &[err, value], provider_type); +} + +pub(super) unsafe fn schedule_node_style_callback3( + callback_bits: f64, + err: f64, + a: f64, + b: f64, + provider_type: &'static str, +) { + let raw = callback_bits.to_bits() & 0x0000_FFFF_FFFF_FFFF; + if !perry_runtime::closure::is_closure_ptr(raw as usize) { + return; + } + perry_runtime::timer::schedule_native_callback(raw as i64, &[err, a, b], provider_type); } #[no_mangle] @@ -122,7 +141,12 @@ pub unsafe extern "C" fn js_crypto_generate_key_async( } else { f64::from_bits(JSValue::pointer(key as *const u8).bits()) }; - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value); + schedule_node_style_callback2( + callback_bits, + f64::from_bits(JSValue::null().bits()), + value, + "KEYGENREQUEST", + ); f64::from_bits(JSValue::undefined().bits()) } @@ -142,18 +166,25 @@ pub unsafe extern "C" fn js_crypto_generate_key_pair_async( let null = f64::from_bits(JSValue::null().bits()); let undefined = f64::from_bits(JSValue::undefined().bits()); if pair.is_null() { - call_node_style_callback3(callback_bits, null, undefined, undefined); + schedule_node_style_callback3( + callback_bits, + null, + undefined, + undefined, + "KEYPAIRGENREQUEST", + ); return undefined; } let public_key = js_object_get_field_by_name(pair, js_string_from_bytes(b"publicKey".as_ptr(), 9)); let private_key = js_object_get_field_by_name(pair, js_string_from_bytes(b"privateKey".as_ptr(), 10)); - call_node_style_callback3( + schedule_node_style_callback3( callback_bits, null, f64::from_bits(public_key.bits()), f64::from_bits(private_key.bits()), + "KEYPAIRGENREQUEST", ); undefined } diff --git a/crates/perry-stdlib/src/crypto/prime.rs b/crates/perry-stdlib/src/crypto/prime.rs index 1e09eef506..7612a77f82 100644 --- a/crates/perry-stdlib/src/crypto/prime.rs +++ b/crates/perry-stdlib/src/crypto/prime.rs @@ -40,10 +40,11 @@ pub unsafe extern "C" fn js_crypto_generate_prime_async( callback_bits: f64, ) -> f64 { let value = js_crypto_generate_prime_sync(size_bits, options_bits); - call_node_style_callback2( + schedule_node_style_callback2( callback_bits, f64::from_bits(JSValue::undefined().bits()), value, + "RANDOMPRIMEREQUEST", ); f64::from_bits(JSValue::undefined().bits()) } @@ -55,10 +56,11 @@ pub unsafe extern "C" fn js_crypto_check_prime_async( callback_bits: f64, ) -> f64 { let result = js_crypto_check_prime_sync(candidate_bits, options_bits); - call_node_style_callback2( + schedule_node_style_callback2( callback_bits, f64::from_bits(JSValue::null().bits()), result, + "CHECKPRIMEREQUEST", ); f64::from_bits(JSValue::undefined().bits()) } diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 7aeeaf4081..8bc773f47c 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -103,7 +103,7 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: if perry_runtime::closure::is_closure_ptr(cb_ptr as usize) { let err = f64::from_bits(JSValue::null().bits()); let args = [err, value]; - perry_runtime::timer::js_set_immediate_callback_args(cb_ptr, args.as_ptr(), 2); + perry_runtime::timer::schedule_native_callback(cb_ptr, &args, "RANDOMBYTESREQUEST"); } f64::from_bits(JSValue::undefined().bits()) } @@ -373,8 +373,24 @@ pub unsafe extern "C" fn js_crypto_native_dispatch( f64::from_bits(JSValue::string_ptr(js_crypto_create_public_key_value(arg(0))).bits()) } "generatePrime" if args_len >= 3 => js_crypto_generate_prime_async(arg(0), arg(1), arg(2)), + "generatePrime" + if args_len == 2 + && perry_runtime::closure::is_closure_ptr( + perry_runtime::value::js_nanbox_get_pointer(arg(1)) as usize, + ) => + { + js_crypto_generate_prime_async(arg(0), undefined, arg(1)) + } "generatePrime" | "generatePrimeSync" => js_crypto_generate_prime_sync(arg(0), arg(1)), "checkPrime" if args_len >= 3 => js_crypto_check_prime_async(arg(0), arg(1), arg(2)), + "checkPrime" + if args_len == 2 + && perry_runtime::closure::is_closure_ptr( + perry_runtime::value::js_nanbox_get_pointer(arg(1)) as usize, + ) => + { + js_crypto_check_prime_async(arg(0), undefined, arg(1)) + } "checkPrime" | "checkPrimeSync" => js_crypto_check_prime_sync(arg(0), arg(1)), "getFips" => 0.0, "setFips" => undefined, @@ -427,6 +443,17 @@ pub unsafe extern "C" fn js_crypto_native_dispatch( js_crypto_scrypt_bytes(bytes_ptr(0), bytes_ptr(1), arg(2), options_ptr) as *mut u8, ) } + // Node callback forms are randomInt(max, callback) and + // randomInt(min, max, callback); both complete asynchronously. + "randomInt" if args_len >= 3 => js_crypto_random_int_async(arg(0), arg(1), arg(2)), + "randomInt" + if args_len == 2 + && perry_runtime::closure::is_closure_ptr( + perry_runtime::value::js_nanbox_get_pointer(arg(1)) as usize, + ) => + { + js_crypto_random_int_async(0.0, arg(0), arg(1)) + } // Node: randomInt(max) → [0,max); randomInt(min,max) → [min,max). "randomInt" if args_len >= 2 => js_crypto_random_int(arg(0), arg(1)), "randomInt" => js_crypto_random_int(0.0, arg(0)), @@ -447,7 +474,12 @@ pub unsafe extern "C" fn js_crypto_random_int_async( callback_bits: f64, ) -> f64 { let n = js_crypto_random_int(min_bits, max_bits); - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), n); + schedule_node_style_callback2( + callback_bits, + f64::from_bits(JSValue::null().bits()), + n, + "RANDOMBYTESREQUEST", + ); f64::from_bits(JSValue::undefined().bits()) } @@ -595,7 +627,12 @@ pub unsafe extern "C" fn js_crypto_random_fill_async( callback_bits: f64, ) -> f64 { let value = js_crypto_random_fill_sync(buf_bits, offset_bits, size_bits); - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value); + schedule_node_style_callback2( + callback_bits, + f64::from_bits(JSValue::null().bits()), + value, + "RANDOMBYTESREQUEST", + ); f64::from_bits(JSValue::undefined().bits()) } @@ -882,6 +919,11 @@ mod tests { unsafe { js_crypto_native_dispatch(method.as_ptr(), method.len(), args.as_ptr(), args.len()); } + assert!( + !CB_FIRED.with(|f| f.get()), + "pbkdf2 callback must not fire synchronously" + ); + perry_runtime::timer::js_callback_timer_tick(); assert!(CB_FIRED.with(|f| f.get()), "pbkdf2 callback must fire"); assert!( CB_ERR_NULLISH.with(|f| f.get()), diff --git a/crates/perry-stdlib/src/events/constructors.rs b/crates/perry-stdlib/src/events/constructors.rs index 9303592b11..f95362821b 100644 --- a/crates/perry-stdlib/src/events/constructors.rs +++ b/crates/perry-stdlib/src/events/constructors.rs @@ -76,7 +76,12 @@ pub unsafe extern "C" fn js_event_emitter_async_resource_new(options: f64) -> Ha let mut emitter = EventEmitterHandle::new(); emitter.capture_rejections = event_emitter_options_capture_rejections(options); emitter.async_resource_handle = async_resource_handle; - register_handle(emitter) + let emitter_handle = register_handle(emitter); + perry_runtime::async_hooks::set_async_resource_event_emitter( + async_resource_handle, + emitter_handle, + ); + emitter_handle } #[no_mangle] @@ -156,3 +161,15 @@ static KEEP_JS_EVENT_EMITTER_NEW_WITH_OPTIONS: unsafe extern "C" fn(f64) -> Hand #[used] static KEEP_JS_EVENT_EMITTER_ASYNC_RESOURCE_NEW: unsafe extern "C" fn(f64) -> Handle = js_event_emitter_async_resource_new; +#[used] +static KEEP_JS_EVENT_EMITTER_ASYNC_RESOURCE_ASYNC_ID: unsafe extern "C" fn(Handle) -> f64 = + js_event_emitter_async_resource_async_id; +#[used] +static KEEP_JS_EVENT_EMITTER_ASYNC_RESOURCE_TRIGGER_ASYNC_ID: unsafe extern "C" fn(Handle) -> f64 = + js_event_emitter_async_resource_trigger_async_id; +#[used] +static KEEP_JS_EVENT_EMITTER_ASYNC_RESOURCE_ASYNC_RESOURCE: unsafe extern "C" fn(Handle) -> f64 = + js_event_emitter_async_resource_async_resource; +#[used] +static KEEP_JS_EVENT_EMITTER_ASYNC_RESOURCE_EMIT_DESTROY: unsafe extern "C" fn(Handle) -> f64 = + js_event_emitter_async_resource_emit_destroy; diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index e5ffae14b9..81bc085ce3 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -1391,6 +1391,13 @@ pub unsafe extern "C" fn js_blob_type(handle: f64) -> *mut StringHeader { /// property dispatch in `value.rs`. Resolved synchronously. #[no_mangle] pub unsafe extern "C" fn js_blob_array_buffer(handle: f64) -> *mut perry_runtime::Promise { + let result = perry_runtime::async_hooks::run_provider_completion("BLOBREADER", || { + perry_runtime::value::js_nanbox_pointer(blob_array_buffer_impl(handle) as i64) + }); + perry_runtime::value::js_nanbox_get_pointer(result) as *mut perry_runtime::Promise +} + +unsafe fn blob_array_buffer_impl(handle: f64) -> *mut perry_runtime::Promise { let promise = perry_runtime::js_promise_new_cross_thread(); let id = handle_id(handle); let body: Vec = BLOB_REGISTRY @@ -1418,7 +1425,10 @@ pub unsafe extern "C" fn js_blob_array_buffer(handle: f64) -> *mut perry_runtime /// hits the `is_registered_buffer` path from #227). #[no_mangle] pub unsafe extern "C" fn js_blob_bytes(handle: f64) -> *mut perry_runtime::Promise { - js_blob_array_buffer(handle) + let result = perry_runtime::async_hooks::run_provider_completion("BLOBREADER", || { + perry_runtime::value::js_nanbox_pointer(blob_array_buffer_impl(handle) as i64) + }); + perry_runtime::value::js_nanbox_get_pointer(result) as *mut perry_runtime::Promise } /// blob.text() — UTF-8-decodes the body bytes into a `StringHeader` and @@ -1427,6 +1437,13 @@ pub unsafe extern "C" fn js_blob_bytes(handle: f64) -> *mut perry_runtime::Promi /// characters; lossy_utf8 produces U+FFFD identically). #[no_mangle] pub unsafe extern "C" fn js_blob_text(handle: f64) -> *mut perry_runtime::Promise { + let result = perry_runtime::async_hooks::run_provider_completion("BLOBREADER", || { + perry_runtime::value::js_nanbox_pointer(blob_text_impl(handle) as i64) + }); + perry_runtime::value::js_nanbox_get_pointer(result) as *mut perry_runtime::Promise +} + +unsafe fn blob_text_impl(handle: f64) -> *mut perry_runtime::Promise { let promise = perry_runtime::js_promise_new_cross_thread(); let id = handle_id(handle); let body: Vec = BLOB_REGISTRY diff --git a/crates/perry-stdlib/src/readline/mod.rs b/crates/perry-stdlib/src/readline/mod.rs index 4b93af6a9c..ca7bdeee27 100644 --- a/crates/perry-stdlib/src/readline/mod.rs +++ b/crates/perry-stdlib/src/readline/mod.rs @@ -615,16 +615,18 @@ fn close_custom_interface(handle: i64) { .flatten(); if let Some(cb_i64) = cb { js_closure_call0(cb_i64 as *const ClosureHeader); + // Release the slot once the close notification has an observer. If the + // custom stream completed before user code could attach `rl.on` + // listeners, retain the closed state temporarily; `js_readline_on` + // replays the buffered lines and close below. This compensates for + // Perry's native-call checkpoint draining Readable.from microtasks + // between adjacent JS statements (#6764). + READLINE_INTERFACES.with(|interfaces| { + if let Some(slot) = interfaces.borrow_mut().get_mut(handle as usize) { + *slot = None; + } + }); } - // Release the slot so the GC scanner stops rooting the closed - // interface's input/output/callbacks. Handles are NOT reused: a stale - // handle to a closed interface must hit the `None` slot (a no-op, like - // Node's ERR_USE_AFTER_CLOSE), not alias a newer interface. - READLINE_INTERFACES.with(|interfaces| { - if let Some(slot) = interfaces.borrow_mut().get_mut(handle as usize) { - *slot = None; - } - }); } fn append_custom_input(handle: i64, chunk: f64) { @@ -665,6 +667,10 @@ fn append_custom_input(handle: i64, chunk: f64) { .flatten(); if let Some(cb_i64) = cb { js_closure_call1(cb_i64 as *const ClosureHeader, callback_arg(&line)); + } else { + let _ = with_interface_mut(handle, |state| { + state.buffered_lines.push_back(line); + }); } } } @@ -863,6 +869,13 @@ fn attach_custom_input(handle: i64, input: f64) { if raw_ptr_from_value(input).is_none() { return; } + // These are native listener closures, so the generic stream emitter needs + // their public arities before it can select call0/call1 correctly. Without + // registration Readable.from-backed interfaces retained the callbacks but + // never delivered `data`/`end`, leaving the top-level readline Promise + // unsettled. + perry_runtime::closure::js_register_closure_arity(custom_input_data as *const u8, 1); + perry_runtime::closure::js_register_closure_arity(custom_input_close as *const u8, 0); // Root every value built here: each later closure/string allocation (and // the JS `.on` calls below) can trigger a moving minor GC, leaving an // unrooted listener pointer in from-space. Re-read handles at each use. @@ -1299,19 +1312,51 @@ pub extern "C" fn js_readline_on( return undefined(); } let event = string_header_to_string(event_ptr); + let mut replay_lines = false; + let mut replay_close = false; if with_interface_mut(handle, |state| { if !state.uses_custom_stream { return false; } match event.as_str() { - "line" => state.line_callback = Some(callback), - "close" => state.close_callback = Some(callback), + "line" => { + state.line_callback = Some(callback); + replay_lines = !state.buffered_lines.is_empty(); + } + "close" => { + state.close_callback = Some(callback); + replay_close = state.closed; + } _ => {} } true }) .unwrap_or(false) { + if replay_lines { + loop { + let next = + with_interface_mut(handle, |state| state.buffered_lines.pop_front()).flatten(); + let Some(line) = next else { + break; + }; + let cb = with_interface(handle, |state| state.line_callback).flatten(); + if let Some(cb_i64) = cb { + js_closure_call1(cb_i64 as *const ClosureHeader, callback_arg(&line)); + } + } + } + if replay_close { + let cb = with_interface(handle, |state| state.close_callback).flatten(); + if let Some(cb_i64) = cb { + js_closure_call0(cb_i64 as *const ClosureHeader); + } + READLINE_INTERFACES.with(|interfaces| { + if let Some(slot) = interfaces.borrow_mut().get_mut(handle as usize) { + *slot = None; + } + }); + } return undefined(); } match event.as_str() { @@ -1679,113 +1724,13 @@ pub use pump::{js_readline_has_active, js_readline_process_pending}; // Tests // --------------------------------------------------------------------------- -/// Test-only helper: bypass the stdin reader and inject a line into the -/// queue. -#[doc(hidden)] #[cfg(test)] -fn test_inject_line(line: &str) { - PENDING_LINES.lock().unwrap().push(line.to_string()); -} - -#[doc(hidden)] -#[cfg(test)] -fn test_inject_chunk(chunk: &[u8]) { - PENDING_DATA.lock().unwrap().push(chunk.to_vec()); -} +mod test_support; #[cfg(test)] mod tests { + use super::test_support::*; use super::*; - use std::sync::{Mutex, MutexGuard}; - - /// All readline tests share PENDING_LINES / PENDING_DATA / EOF_REACHED - /// / CLOSE_FIRED / RAW_MODE / the thread_local callback cells, so - /// `cargo test`'s default parallel runner races them — most visibly, - /// `has_active_reflects_state`'s `test_inject_line("x")` → - /// `assert has_active == 1` window can be observed mid-flight by - /// `injected_line_drains_via_test_helper`'s `reset()` (which clears - /// PENDING_LINES) and flake. Serialize every state-touching test - /// through one process-global lock acquired by `reset()`. Pure- - /// function tests (`parse_keypress_*`) don't call `reset()` and - /// continue running in parallel. - static TEST_LOCK: Mutex<()> = Mutex::new(()); - thread_local! { - static DATA_COUNT: RefCell = const { RefCell::new(0) }; - static KEYPRESS_NAMES: RefCell> = const { RefCell::new(Vec::new()) }; - } - - extern "C" fn count_data_callback(_closure: *const ClosureHeader, _chunk: f64) -> f64 { - DATA_COUNT.with(|count| *count.borrow_mut() += 1); - undefined() - } - - fn data_counter_callback() -> i64 { - js_closure_alloc(count_data_callback as *const u8, 0) as i64 - } - - extern "C" fn count_readable_callback(_closure: *const ClosureHeader) -> f64 { - DATA_COUNT.with(|count| *count.borrow_mut() += 1); - undefined() - } - - fn readable_counter_callback() -> i64 { - js_closure_alloc(count_readable_callback as *const u8, 0) as i64 - } - - extern "C" fn record_keypress_callback( - _closure: *const ClosureHeader, - _seq: f64, - key_obj: f64, - ) -> f64 { - let name = object_field(key_obj, b"name") - .map(value_to_string) - .unwrap_or_default(); - KEYPRESS_NAMES.with(|names| names.borrow_mut().push(name)); - undefined() - } - - fn keypress_recorder_callback() -> i64 { - js_closure_alloc(record_keypress_callback as *const u8, 0) as i64 - } - - fn event_name(name: &str) -> *mut StringHeader { - js_string_from_bytes(name.as_ptr(), name.len() as u32) - } - - fn reset() -> MutexGuard<'static, ()> { - let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - DATA_COUNT.with(|count| *count.borrow_mut() = 0); - KEYPRESS_NAMES.with(|names| names.borrow_mut().clear()); - QUESTION_CALLBACK.with(|c| *c.borrow_mut() = None); - LINE_CALLBACK.with(|c| *c.borrow_mut() = None); - CLOSE_CALLBACK.with(|c| *c.borrow_mut() = None); - if let Ok(mut v) = DATA_CALLBACKS.lock() { - v.clear(); - } - if let Ok(mut v) = KEYPRESS_CALLBACKS.lock() { - v.clear(); - } - if let Ok(mut v) = READABLE_CALLBACKS.lock() { - v.clear(); - } - PENDING_LINES.lock().unwrap().clear(); - PENDING_DATA.lock().unwrap().clear(); - PENDING_ESCAPE.lock().unwrap().clear(); - EOF_REACHED.store(false, Ordering::Release); - READABLE_EOF_NOTIFIED.store(false, Ordering::Release); - STDIN_PAUSED.store(false, Ordering::Release); - STDIN_REFED.store(true, Ordering::Release); - STDIN_DESTROYED.store(false, Ordering::Release); - CLOSE_FIRED.with(|f| *f.borrow_mut() = false); - RAW_MODE.store(false, Ordering::Release); - STDIN_DATA_FLOWING.store(false, Ordering::Release); - READLINE_INTERFACES.with(|interfaces| interfaces.borrow_mut().clear()); - NEXT_READLINE_HANDLE.with(|next| *next.borrow_mut() = 2); - // ensure_reader_started never spawns a real thread under cfg(test), - // so the started flag is safe to clear between tests. - READER_STARTED.store(false, Ordering::Release); - guard - } #[test] fn close_without_callbacks_is_noop() { diff --git a/crates/perry-stdlib/src/readline/test_support.rs b/crates/perry-stdlib/src/readline/test_support.rs new file mode 100644 index 0000000000..39295b7e76 --- /dev/null +++ b/crates/perry-stdlib/src/readline/test_support.rs @@ -0,0 +1,87 @@ +use super::*; +use std::sync::{Mutex, MutexGuard}; + +static TEST_LOCK: Mutex<()> = Mutex::new(()); +thread_local! { + pub(super) static DATA_COUNT: RefCell = const { RefCell::new(0) }; + pub(super) static KEYPRESS_NAMES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub(super) fn test_inject_line(line: &str) { + PENDING_LINES.lock().unwrap().push(line.to_string()); +} + +pub(super) fn test_inject_chunk(chunk: &[u8]) { + PENDING_DATA.lock().unwrap().push(chunk.to_vec()); +} + +extern "C" fn count_data_callback(_closure: *const ClosureHeader, _chunk: f64) -> f64 { + DATA_COUNT.with(|count| *count.borrow_mut() += 1); + undefined() +} + +pub(super) fn data_counter_callback() -> i64 { + js_closure_alloc(count_data_callback as *const u8, 0) as i64 +} + +extern "C" fn count_readable_callback(_closure: *const ClosureHeader) -> f64 { + DATA_COUNT.with(|count| *count.borrow_mut() += 1); + undefined() +} + +pub(super) fn readable_counter_callback() -> i64 { + js_closure_alloc(count_readable_callback as *const u8, 0) as i64 +} + +extern "C" fn record_keypress_callback( + _closure: *const ClosureHeader, + _seq: f64, + key_obj: f64, +) -> f64 { + let name = object_field(key_obj, b"name") + .map(value_to_string) + .unwrap_or_default(); + KEYPRESS_NAMES.with(|names| names.borrow_mut().push(name)); + undefined() +} + +pub(super) fn keypress_recorder_callback() -> i64 { + js_closure_alloc(record_keypress_callback as *const u8, 0) as i64 +} + +pub(super) fn event_name(name: &str) -> *mut StringHeader { + js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +pub(super) fn reset() -> MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + DATA_COUNT.with(|count| *count.borrow_mut() = 0); + KEYPRESS_NAMES.with(|names| names.borrow_mut().clear()); + QUESTION_CALLBACK.with(|c| *c.borrow_mut() = None); + LINE_CALLBACK.with(|c| *c.borrow_mut() = None); + CLOSE_CALLBACK.with(|c| *c.borrow_mut() = None); + if let Ok(mut v) = DATA_CALLBACKS.lock() { + v.clear(); + } + if let Ok(mut v) = KEYPRESS_CALLBACKS.lock() { + v.clear(); + } + if let Ok(mut v) = READABLE_CALLBACKS.lock() { + v.clear(); + } + PENDING_LINES.lock().unwrap().clear(); + PENDING_DATA.lock().unwrap().clear(); + PENDING_ESCAPE.lock().unwrap().clear(); + EOF_REACHED.store(false, Ordering::Release); + READABLE_EOF_NOTIFIED.store(false, Ordering::Release); + STDIN_PAUSED.store(false, Ordering::Release); + STDIN_REFED.store(true, Ordering::Release); + STDIN_DESTROYED.store(false, Ordering::Release); + CLOSE_FIRED.with(|f| *f.borrow_mut() = false); + RAW_MODE.store(false, Ordering::Release); + STDIN_DATA_FLOWING.store(false, Ordering::Release); + READLINE_INTERFACES.with(|interfaces| interfaces.borrow_mut().clear()); + NEXT_READLINE_HANDLE.with(|next| *next.borrow_mut() = 2); + READER_STARTED.store(false, Ordering::Release); + guard +} diff --git a/crates/perry-stdlib/src/tls.rs b/crates/perry-stdlib/src/tls.rs index 2b600de645..5361d60715 100644 --- a/crates/perry-stdlib/src/tls.rs +++ b/crates/perry-stdlib/src/tls.rs @@ -29,12 +29,17 @@ const TLS_DISPATCH_MISSING_BITS: u64 = TAG_UNDEFINED_BITS; mod client_verifier; mod dispatch; +mod event_pump; mod module_api; mod socket_api; // Re-export the handle-dispatch and module-level entry points so // `crate::tls::…` (and the `pub use tls::*` glob in `lib.rs`) keep resolving // them exactly as before the split. pub use dispatch::{dispatch_tls_handle, dispatch_tls_property, should_dispatch_tls_handle}; +pub use event_pump::{ + is_tls_server_handle, is_tls_socket_handle, js_tls_has_active_handles, js_tls_process_pending, + record_tls_client_handle, +}; pub use module_api::{ js_tls_check_server_identity, js_tls_convert_alpn_protocols, js_tls_create_secure_context, js_tls_get_ca_certificates, js_tls_get_ciphers, js_tls_native_dispatch, @@ -1838,162 +1843,38 @@ pub unsafe extern "C" fn js_tls_server_set_ticket_keys(handle: i64, value_bits: } } -pub fn record_tls_client_handle(handle: i64) { - if handle <= 0 { - return; - } - crate::common::async_bridge::ensure_pump_registered(); - ensure_tls_gc_scanner_registered(); - if !perry_runtime::tls::is_tls_client_handle(handle) { - unsafe { - perry_runtime::tls::js_tls_client_record_start( - handle, - undefined(), - std::ptr::null(), - 0, - ); - } - } -} - -pub fn is_tls_server_handle(handle: i64) -> bool { - servers().lock().unwrap().contains_key(&handle) -} - -pub fn is_tls_socket_handle(handle: i64) -> bool { - sockets().lock().unwrap().contains_key(&handle) - || perry_runtime::tls::is_tls_client_handle(handle) -} - -#[no_mangle] -pub unsafe extern "C" fn js_tls_process_pending() -> i32 { - let mut events = { - let mut pending = pending_events().lock().unwrap(); - std::mem::take(&mut *pending) - }; - let count = events.len() as i32; - for event in events.drain(..) { - match event { - PendingTlsEvent::ServerListening(server_id) => { - let callbacks = { - let mut all = listeners().lock().unwrap(); - all.get_mut(&server_id) - .and_then(|per| per.remove("listening")) - .unwrap_or_default() - }; - for cb in callbacks { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); - } - } - drain_once_listeners(server_id, "listening"); - } - 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); - } - } - drain_once_listeners(server_id, event_name); - } - } - PendingTlsEvent::ServerClose(server_id) => { - let callbacks = { - let mut all = listeners().lock().unwrap(); - all.get_mut(&server_id) - .and_then(|per| per.remove("close")) - .unwrap_or_default() - }; - for cb in callbacks { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); - } - } - servers().lock().unwrap().remove(&server_id); - listeners().lock().unwrap().remove(&server_id); - 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); - } - } - 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); - } - } - 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); - } - } - 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); - } - } - 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); - } - } - sockets().lock().unwrap().remove(&socket_id); - listeners().lock().unwrap().remove(&socket_id); - 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); - } - } - drain_once_listeners(socket_id, "error"); - } - } - } - count -} - -pub fn js_tls_has_active_handles() -> i32 { - if !pending_events().lock().unwrap().is_empty() { - return 1; - } - if servers() - .lock() - .unwrap() - .values() - .any(|server| server.listening || (server.closing && server.active_connections > 0)) - { - return 1; - } - if sockets() - .lock() - .unwrap() - .values() - .any(|s| s.server_side && s.cmd_tx.is_some()) - { - return 1; - } - 0 -} +// TLS methods are called only by symbols emitted into generated object files. +// Keep every public FFI entry point in the auto-optimized stdlib archive so +// whole-program LTO cannot discard them before the generated object is linked. +struct KeepTlsFfi( + #[allow(dead_code)] [*const (); N], // link-time keepalive anchor; field never read +); +// SAFETY: the pointers are retained for linking only and are never read or +// dereferenced, so sharing the static anchor between threads is sound. +unsafe impl Sync for KeepTlsFfi {} +#[used] +static KEEP_TLS_FFI: KeepTlsFfi<23> = KeepTlsFfi([ + js_tls_create_server as *const (), + js_tls_tlssocket_constructor as *const (), + js_tls_server_listen as *const (), + js_tls_server_close as *const (), + js_tls_server_address as *const (), + js_tls_server_on as *const (), + js_tls_server_once as *const (), + js_tls_server_remove_listener as *const (), + js_tls_server_remove_all_listeners as *const (), + js_tls_server_listener_count as *const (), + js_tls_server_event_names as *const (), + js_tls_server_set_secure_context as *const (), + js_tls_server_get_ticket_keys as *const (), + js_tls_server_set_ticket_keys as *const (), + js_tls_socket_get_protocol as *const (), + js_tls_socket_get_cipher as *const (), + js_tls_socket_get_peer_certificate as *const (), + js_tls_socket_get_certificate as *const (), + js_tls_socket_get_session as *const (), + js_tls_socket_is_session_reused as *const (), + js_tls_socket_export_keying_material as *const (), + js_tls_socket_set_max_send_fragment as *const (), + js_tls_process_pending as *const (), +]); diff --git a/crates/perry-stdlib/src/tls/event_pump.rs b/crates/perry-stdlib/src/tls/event_pump.rs new file mode 100644 index 0000000000..0d691ebabd --- /dev/null +++ b/crates/perry-stdlib/src/tls/event_pump.rs @@ -0,0 +1,218 @@ +//! Main-thread TLS event delivery and active-handle accounting. + +use super::*; + +pub fn record_tls_client_handle(handle: i64) { + if handle <= 0 { + return; + } + crate::common::async_bridge::ensure_pump_registered(); + ensure_tls_gc_scanner_registered(); + if !perry_runtime::tls::is_tls_client_handle(handle) { + unsafe { + perry_runtime::tls::js_tls_client_record_start( + handle, + undefined(), + std::ptr::null(), + 0, + ); + } + } +} + +pub fn is_tls_server_handle(handle: i64) -> bool { + servers().lock().unwrap().contains_key(&handle) +} + +pub fn is_tls_socket_handle(handle: i64) -> bool { + sockets().lock().unwrap().contains_key(&handle) + || perry_runtime::tls::is_tls_client_handle(handle) +} + +#[no_mangle] +pub unsafe extern "C" fn js_tls_process_pending() -> i32 { + let mut events = { + let mut pending = pending_events().lock().unwrap(); + std::mem::take(&mut *pending) + }; + let count = events.len() as i32; + for event in events.drain(..) { + match event { + PendingTlsEvent::ServerListening(server_id) => { + let callbacks = { + let mut all = listeners().lock().unwrap(); + all.get_mut(&server_id) + .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 { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call0(cb); + } + } + drain_once_listeners(server_id, "listening"); + } + PendingTlsEvent::ServerSecureConnection(server_id, socket_id) => { + let socket = nanbox_handle(socket_id); + for event_name in ["secureConnection", "connection"] { + 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); + } + } + PendingTlsEvent::ServerClose(server_id) => { + let callbacks = { + let mut all = listeners().lock().unwrap(); + all.get_mut(&server_id) + .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 { + let cb = cb.get_raw_const_ptr::(); + if !cb.is_null() { + js_closure_call0(cb); + } + } + servers().lock().unwrap().remove(&server_id); + listeners().lock().unwrap().remove(&server_id); + once_flags().lock().unwrap().remove(&server_id); + } + PendingTlsEvent::ServerError(server_id, msg) => { + 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 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 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) => { + 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) => { + 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); + listeners().lock().unwrap().remove(&socket_id); + once_flags().lock().unwrap().remove(&socket_id); + } + PendingTlsEvent::SocketError(socket_id, msg) => { + 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"); + } + } + } + count +} + +pub fn js_tls_has_active_handles() -> i32 { + if !pending_events().lock().unwrap().is_empty() { + return 1; + } + if servers() + .lock() + .unwrap() + .values() + .any(|server| server.listening || (server.closing && server.active_connections > 0)) + { + return 1; + } + if sockets() + .lock() + .unwrap() + .values() + .any(|s| s.server_side && s.cmd_tx.is_some()) + { + return 1; + } + 0 +} diff --git a/crates/perry-stdlib/src/webcrypto/aes.rs b/crates/perry-stdlib/src/webcrypto/aes.rs index 290b4e002c..27af9d877a 100644 --- a/crates/perry-stdlib/src/webcrypto/aes.rs +++ b/crates/perry-stdlib/src/webcrypto/aes.rs @@ -763,7 +763,7 @@ pub unsafe extern "C" fn js_webcrypto_encrypt( Some(c) => c, None => return reject_with_dom_exception("OperationError", "The operation failed"), }; - return resolve_with_bytes(&ciphertext); + return resolve_with_bytes_provider(&ciphertext, "CIPHERREQUEST"); } if algo_name.eq_ignore_ascii_case("AES-CBC") { let key_addr = strip_ptr(key_bits.to_bits()); @@ -801,7 +801,7 @@ pub unsafe extern "C" fn js_webcrypto_encrypt( Some(c) => c, None => return reject_with_dom_exception("OperationError", "The operation failed"), }; - return resolve_with_bytes(&ciphertext); + return resolve_with_bytes_provider(&ciphertext, "CIPHERREQUEST"); } if algo_name.eq_ignore_ascii_case("AES-CTR") { let key_addr = strip_ptr(key_bits.to_bits()); @@ -839,7 +839,7 @@ pub unsafe extern "C" fn js_webcrypto_encrypt( Some(c) => c, None => return reject_with_dom_exception("OperationError", "The operation failed"), }; - return resolve_with_bytes(&ciphertext); + return resolve_with_bytes_provider(&ciphertext, "CIPHERREQUEST"); } if algo_name.eq_ignore_ascii_case("ChaCha20-Poly1305") { let key_addr = strip_ptr(key_bits.to_bits()); @@ -877,7 +877,7 @@ pub unsafe extern "C" fn js_webcrypto_encrypt( Some(c) => c, None => return reject_with_dom_exception("OperationError", "The operation failed"), }; - return resolve_with_bytes(&ciphertext); + return resolve_with_bytes_provider(&ciphertext, "CIPHERREQUEST"); } if algo_name.eq_ignore_ascii_case("AES-OCB") { let key_addr = strip_ptr(key_bits.to_bits()); @@ -915,7 +915,7 @@ pub unsafe extern "C" fn js_webcrypto_encrypt( Some(c) => c, None => return reject_with_dom_exception("OperationError", "The operation failed"), }; - return resolve_with_bytes(&ciphertext); + return resolve_with_bytes_provider(&ciphertext, "CIPHERREQUEST"); } if !algo_name.eq_ignore_ascii_case("AES-GCM") { return reject_with_dom_exception("NotSupportedError", "Unrecognized algorithm name"); @@ -949,7 +949,7 @@ pub unsafe extern "C" fn js_webcrypto_encrypt( Some(c) => c, None => return reject_with_dom_exception("OperationError", "The operation failed"), }; - resolve_with_bytes(&ciphertext) + resolve_with_bytes_provider(&ciphertext, "CIPHERREQUEST") } /// `crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData? }, key, data)` diff --git a/crates/perry-stdlib/src/webcrypto/digest.rs b/crates/perry-stdlib/src/webcrypto/digest.rs index ddb549338c..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::js_set_immediate_callback(cl as i64); - 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 2cc75ec999..762efecdcc 100644 --- a/crates/perry-stdlib/src/webcrypto/hmac.rs +++ b/crates/perry-stdlib/src/webcrypto/hmac.rs @@ -204,7 +204,7 @@ pub unsafe extern "C" fn js_webcrypto_sign( } else { return reject_with_dom_exception("NotSupportedError", "Unrecognized algorithm name"); }; - resolve_with_bytes(&sig) + resolve_with_bytes_provider(&sig, "SIGNREQUEST") } /// `crypto.subtle.verify(algorithm, key, signature, data)` → Promise @@ -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" { @@ -431,7 +431,7 @@ pub unsafe extern "C" fn js_webcrypto_verify( } else { return reject_with_dom_exception("NotSupportedError", "Unrecognized algorithm name"); }; - resolve_with_bool(ok) + resolve_with_bool_provider(ok, "SIGNREQUEST") } /// Algorithm-arg coercion shared by sign / verify: accepts a string diff --git a/crates/perry-stdlib/src/webcrypto/util.rs b/crates/perry-stdlib/src/webcrypto/util.rs index 95c440ccf3..f647b7b5e2 100644 --- a/crates/perry-stdlib/src/webcrypto/util.rs +++ b/crates/perry-stdlib/src/webcrypto/util.rs @@ -916,6 +916,16 @@ pub(super) fn resolve_with_bits(bits: u64) -> *mut Promise { js_promise_resolved(f64::from_bits(bits)) } +pub(super) fn resolve_with_bits_provider(bits: u64, provider_type: &'static str) -> *mut Promise { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(f64::from_bits(bits)); + let result = perry_runtime::async_hooks::run_provider_completion(provider_type, || { + let promise = js_promise_resolved(value.get_nanbox_f64()); + f64::from_bits(JSValue::pointer(promise as *const u8).bits()) + }); + perry_runtime::value::js_nanbox_get_pointer(result) as *mut Promise +} + /// Construct a DOMException and return a rejected Promise carrying it. pub(super) unsafe fn reject_with_dom_exception(name: &str, message: &str) -> *mut Promise { let name_str = perry_runtime::js_string_from_bytes(name.as_ptr(), name.len() as u32); @@ -940,9 +950,28 @@ pub(super) unsafe fn resolve_with_bytes(bytes: &[u8]) -> *mut Promise { resolve_with_bits(val) } -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_bytes_provider( + bytes: &[u8], + provider_type: &'static str, +) -> *mut Promise { + let buf = alloc_uint8array_from_slice(bytes); + if buf.is_null() { + return reject_with_dom_exception("OperationError", "The operation failed"); + } + let val = JSValue::pointer(buf as *const u8).bits(); + resolve_with_bits_provider(val, provider_type) +} + +pub(super) unsafe fn resolve_with_bool_provider( + b: bool, + provider_type: &'static str, +) -> *mut Promise { + let bits = if b { + JSValue::bool(true).bits() + } else { + JSValue::bool(false).bits() + }; + resolve_with_bits_provider(bits, provider_type) } pub(super) fn compute_digest(algo: HashAlgo, data: &[u8]) -> Vec { diff --git a/crates/perry-stdlib/src/worker_threads.rs b/crates/perry-stdlib/src/worker_threads.rs index 3b94aff3fb..d434249e9b 100644 --- a/crates/perry-stdlib/src/worker_threads.rs +++ b/crates/perry-stdlib/src/worker_threads.rs @@ -208,6 +208,8 @@ struct WorkerRecord { alive: bool, refed: bool, terminate_promise: Option, + async_resources: [perry_runtime::async_hooks::AsyncResourceIds; 3], + async_resource_bits: [u64; 3], } struct WorkerListener { @@ -301,6 +303,9 @@ fn scan_worker_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_> if let Some(promise) = worker.terminate_promise.as_mut() { visitor.visit_usize_slot(promise); } + for resource in &mut worker.async_resource_bits { + visitor.visit_nanbox_u64_slot(resource); + } } } } @@ -1193,6 +1198,31 @@ pub extern "C" fn js_worker_threads_worker_new(entry_ptr: i64, options: f64) -> }; let (tx, rx) = mpsc::channel::(); let worker_obj = worker_object(worker_id, &options_state); + let resource_scope = perry_runtime::gc::RuntimeHandleScope::new(); + let mut async_resources = [perry_runtime::async_hooks::AsyncResourceIds { + async_id: 0, + trigger_async_id: 0, + }; 3]; + let mut resource_handles = Vec::with_capacity(3); + for (index, type_name) in ["WORKER", "MESSAGEPORT", "MESSAGEPORT"] + .into_iter() + .enumerate() + { + let resource = perry_runtime::object::js_object_alloc_null_proto(0, 0); + let resource_handle = resource_scope + .root_nanbox_f64(perry_runtime::value::js_nanbox_pointer(resource as i64)); + async_resources[index] = perry_runtime::async_hooks::init_resource( + type_name, + resource_handle.get_nanbox_f64(), + true, + ); + resource_handles.push(resource_handle); + } + let async_resource_bits = [ + resource_handles[0].get_nanbox_f64().to_bits(), + resource_handles[1].get_nanbox_f64().to_bits(), + resource_handles[2].get_nanbox_f64().to_bits(), + ]; WORKERS.lock().unwrap().insert( worker_id, WorkerRecord { @@ -1202,6 +1232,8 @@ pub extern "C" fn js_worker_threads_worker_new(entry_ptr: i64, options: f64) -> alive: true, refed: true, terminate_promise: None, + async_resources, + async_resource_bits, }, ); diff --git a/crates/perry-stdlib/src/worker_threads/worker_pump.rs b/crates/perry-stdlib/src/worker_threads/worker_pump.rs index 994ac9613b..6e05f1d72e 100644 --- a/crates/perry-stdlib/src/worker_threads/worker_pump.rs +++ b/crates/perry-stdlib/src/worker_threads/worker_pump.rs @@ -127,14 +127,22 @@ pub extern "C" fn js_worker_threads_process_pending() -> i32 { processed += 1; } WorkerEvent::Exit(worker_id, code) => { - let terminate_promise = + let (terminate_promise, async_resources) = if let Some(worker) = WORKERS.lock().unwrap().get_mut(&worker_id) { worker.alive = false; - worker.terminate_promise.take() + ( + worker.terminate_promise.take(), + Some(worker.async_resources), + ) } else { - None + (None, None) }; dispatch_worker_event(worker_id, "exit", Some(code as f64)); + if let Some(async_resources) = async_resources { + for resource in async_resources { + perry_runtime::async_hooks::destroy(resource.async_id); + } + } if let Some(promise) = terminate_promise { super::async_shim::queue_promise_resolution( promise, @@ -205,7 +213,11 @@ pub extern "C" fn js_worker_threads_has_pending() -> i32 { fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { // Collect (callback, web_event) pairs, then invoke OUTSIDE the WORKERS lock — // a listener may re-enter postMessage / terminate, which needs the lock again. - let (object_bits, callbacks): (u64, Vec<(u64, bool)>) = { + let (object_bits, callbacks, async_resources): ( + u64, + Vec<(u64, bool)>, + [perry_runtime::async_hooks::AsyncResourceIds; 3], + ) = { let mut workers = WORKERS.lock().unwrap(); let Some(worker) = workers.get_mut(&worker_id) else { return; @@ -222,7 +234,7 @@ fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { callbacks }) .unwrap_or_default(); - (worker.object_bits, callbacks) + (worker.object_bits, callbacks, worker.async_resources) }; // Web-style `addEventListener` listeners receive a `MessageEvent` wrapper @@ -244,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"), @@ -289,4 +307,5 @@ fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { perry_runtime::closure::js_closure_call0(closure); } } + 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 ccade48bd6..0b4bd1902e 100644 --- a/crates/perry-stdlib/src/zlib.rs +++ b/crates/perry-stdlib/src/zlib.rs @@ -439,10 +439,16 @@ unsafe fn queue_zlib_callback(codec: Codec, data_value: f64, callback_value: f64 let result = run_one_shot_codec(codec, &data).map_err(|e| e.to_string()); crate::common::async_bridge::ensure_pump_registered(); ensure_zlib_gc_scanner(); + let resource = perry_runtime::js_object_alloc_null_proto(0, 0); + let async_ids = perry_runtime::async_hooks::init_resource( + "ZLIB", + perry_runtime::js_nanbox_pointer(resource as i64), + true, + ); ZLIB_PENDING_EVENTS .lock() .unwrap() - .push(ZlibEvent::OneShotCallback(callback, result)); + .push(ZlibEvent::OneShotCallback(callback, result, async_ids)); perry_runtime::event_pump::js_notify_main_thread(); } @@ -625,6 +631,7 @@ pub unsafe extern "C" fn js_zlib_zstd_decompress(data_value: f64, callback_value // ============================================================================ struct ZlibStreamState { + async_ids: perry_runtime::async_hooks::AsyncResourceIds, codec: Codec, /// Compression level resolved from the factory's `{ level }` option /// (#4917) — kept so `.reset()` rebuilds the codec at the same level. @@ -649,7 +656,11 @@ enum ZlibEvent { /// `.flush(cb)` completion callback — invoked after its flushed 'data'. Callback(i64), /// One-shot `zlib.gzip(data, cb)` style completion callback. - OneShotCallback(i64, Result, String>), + OneShotCallback( + i64, + Result, String>, + perry_runtime::async_hooks::AsyncResourceIds, + ), } lazy_static::lazy_static! { @@ -699,7 +710,7 @@ fn scan_zlib_roots(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { if let Ok(mut pending) = ZLIB_PENDING_EVENTS.lock() { for ev in pending.iter_mut() { match ev { - ZlibEvent::Callback(cb) | ZlibEvent::OneShotCallback(cb, _) => { + ZlibEvent::Callback(cb) | ZlibEvent::OneShotCallback(cb, _, _) => { visitor.visit_i64_slot(cb); } _ => {} @@ -718,12 +729,23 @@ fn next_zlib_id() -> i64 { id } +fn init_zlib_resource() -> perry_runtime::async_hooks::AsyncResourceIds { + let resource = perry_runtime::js_object_alloc_null_proto(0, 0); + perry_runtime::async_hooks::init_resource( + "ZLIB", + perry_runtime::js_nanbox_pointer(resource as i64), + true, + ) +} + fn create_zlib_stream(codec: Codec, level: Compression) -> i64 { ensure_zlib_gc_scanner(); let id = next_zlib_id(); + let async_ids = init_zlib_resource(); ZLIB_STREAMS.lock().unwrap().insert( id, ZlibStreamState { + async_ids, codec, level, codec_state: make_codec_state(codec, level), @@ -1322,6 +1344,17 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { }; let count = events.len() as i32; for ev in events { + let event_ids = match &ev { + ZlibEvent::Data(id, _) | ZlibEvent::End(id) | ZlibEvent::Error(id, _) => ZLIB_STREAMS + .lock() + .ok() + .and_then(|streams| streams.get(id).map(|stream| stream.async_ids)), + _ => None, + }; + if let Some(ids) = event_ids { + perry_runtime::async_hooks::enter_resource_scope(ids); + } + let mut destroy_after_dispatch = None; match ev { ZlibEvent::Data(id, bytes) => { publish_zlib_bytes_written(id); @@ -1363,13 +1396,19 @@ 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 { js_closure_call0(cb as *const ClosureHeader); } } - ZlibEvent::OneShotCallback(cb, result) => { + ZlibEvent::OneShotCallback(cb, result, ids) => { + // Node exposes two ZLIB provider phases for one-shot helpers: + // native compression completion, followed by delivery of the + // JavaScript callback. They intentionally share one resource. + perry_runtime::async_hooks::run_resource_scope(ids, || {}); + perry_runtime::async_hooks::enter_resource_scope(ids); if cb != 0 { match result { Ok(bytes) => { @@ -1398,6 +1437,8 @@ 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); @@ -1408,8 +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 5ca8b4c2db..776e0e5c6a 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -49,6 +49,8 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // to a shadow frame. It changes which functions carry statepoints, so it // changes the generated code and must be a cache input. "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_GC_SAFEPOINT_ONLY", "PERRY_INLINE_SHADOW_SLOT", "PERRY_DISABLE_BUFFER_FAST_PATH", @@ -139,6 +141,7 @@ const BUILD_CACHE_ENV_EXCLUSIONS: &[&str] = &[ "PERRY_LLVM_DIFF_DIR", "PERRY_REPSEL_DEBUG", "PERRY_STATEPOINT_REPORT", + // Writes malformed dialect IR for diagnostics without changing emitted code. // `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/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 2c6ed3d854..ddaeaab8f9 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -738,6 +738,9 @@ fn collect_module_one( return; } for p in &set { + if p.starts_with("data:text/javascript,") { + ctx.uses_data_url_dynamic_import = true; + } if !new_dyn_imports.contains(p) { new_dyn_imports.push(p.clone()); } diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index c0d9d1a9f5..e341b40a4e 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -380,6 +380,13 @@ pub(crate) fn build_optimized_libs( if original_features.contains(&"bundled-net") { features.insert("external-net-pump"); } + // perry-ext-net owns client sockets after the well-known flip, + // but TLS servers and TLSSocket introspection still live in + // perry-stdlib. Retain that surface without re-enabling + // `bundled-net` (and its colliding socket symbols). + if original_features.contains(&"tls") { + features.insert("external-tls-server"); + } // #1843 — when the flip strips the compression base feature and // routes `node:zlib` to perry-ext-zlib, activate // `external-zlib-pump` so perry-stdlib's main-thread pump + @@ -453,6 +460,13 @@ pub(crate) fn build_optimized_libs( // them would drop unresolved externs at link time. if matches!(module_normalized, "http" | "https" | "http2") { features.insert("external-http-server-pump"); + // perry-ext-http depends on perry-ext-net, whose TLS client + // object calls back into perry-stdlib's main-thread + // preflight hook. Retain that provider even for plain HTTP: + // the static archive can pull the shared socket object before + // a TLS-specific API is used, and otherwise leaves + // `js_tls_client_preflight` undefined at link time. + features.insert("external-tls-server"); } // Issue #769 — when `node:http` / `node:https` routes to // perry-ext-http, also activate the client-side pump so the diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index 543f0280e9..4cb562c009 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -146,7 +146,9 @@ pub(crate) fn auto_optimized_cache_key( needs_node_test(ctx), // #6559: dyn-eval presence changes the built archive, so it must // key the freshness stamp like every other runtime feature toggle. - perry_hir::has_deferred_dynamic_code_sites(), + perry_hir::has_deferred_dynamic_code_sites() + || ctx.native_module_imports.contains("vm") + || ctx.uses_data_url_dynamic_import, format!( "{}{}{}", size_opt_level().unwrap_or("off"), @@ -215,13 +217,16 @@ pub(crate) fn auto_optimized_cross_features( // `Intl.*` namespace surface — see perry-runtime's `intl-namespace`. // A deferred dynamic-code site can construct `Intl.…` from a runtime // string, so force it on there too (mirrors the dyn-eval regex rule). - if ctx.uses_intl_namespace || perry_hir::has_deferred_dynamic_code_sites() { + let needs_dyn_eval = perry_hir::has_deferred_dynamic_code_sites() + || ctx.native_module_imports.contains("vm") + || ctx.uses_data_url_dynamic_import; + if ctx.uses_intl_namespace || needs_dyn_eval { cross_features.push("perry-runtime/intl-namespace".to_string()); } // Per-namespace globalThis member tables — see perry-runtime's `global-*`. // A deferred dynamic-code site can reach any namespace by runtime string, // so force all four on there (mirrors the intl-namespace rule). - let dynamic_code = perry_hir::has_deferred_dynamic_code_sites(); + let dynamic_code = needs_dyn_eval; for (used, feat) in [ (ctx.uses_global_math, "global-math"), (ctx.uses_global_json, "global-json"), @@ -273,7 +278,7 @@ pub(crate) fn auto_optimized_cross_features( // interpreter. The generated code of the schema-codegen ecosystem (ajv) // also leans on regex literals (`key.replace(/~/g, …)`), so the regex // engine rides along even when the program's own source never uses one. - if perry_hir::has_deferred_dynamic_code_sites() { + if needs_dyn_eval { cross_features.push("perry-runtime/dyn-eval".to_string()); if !ctx.uses_regex { cross_features.push("perry-runtime/regex-engine".to_string()); diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index ac6da186c3..b47d77718f 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -1099,6 +1099,26 @@ fn auto_optimize_always_includes_keepalive_anchors() { ); } +#[test] +fn data_url_dynamic_import_enables_dyn_eval_and_changes_cache_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let empty_features = std::collections::BTreeSet::new(); + let without = CompilationContext::new(dir.path().to_path_buf()); + let mut with_data_url = CompilationContext::new(dir.path().to_path_buf()); + with_data_url.uses_data_url_dynamic_import = true; + + let cross = auto_optimized_cross_features(&with_data_url, &empty_features, &[]); + assert!( + cross.iter().any(|f| f == "perry-runtime/dyn-eval"), + "data URL modules require the dyn-eval runtime, got {cross:?}" + ); + assert_ne!( + auto_optimized_cache_key("", true, false, None, &with_data_url), + auto_optimized_cache_key("", true, false, None, &without), + "a runtime without dyn-eval must not be reused for data URL imports" + ); +} + /// The `keepalive-anchors` feature must NOT be conditional on /// `PERRY_LLVM_BITCODE_LINK` — the classic link path needs it too. #[test] diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 2c2e756791..eafa7ac4fe 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -739,6 +739,10 @@ pub struct CompilationContext { /// (e.g. "mysql2", "fastify", "ws"). Used by `--minimal-stdlib` to /// compute the smallest perry-stdlib feature set that satisfies them. pub native_module_imports: BTreeSet, + /// Whether a dynamic import can resolve to a JavaScript `data:` URL. + /// The runtime evaluates these modules through the dyn-eval interpreter, + /// so auto-optimized archives must retain that otherwise optional feature. + pub uses_data_url_dynamic_import: bool, /// Whether any TS module calls global `fetch()` (which routes to /// reqwest in perry-stdlib's http-client feature). pub uses_fetch: bool, @@ -1182,6 +1186,7 @@ impl CompilationContext { needs_geisterhand: false, geisterhand_port: 7676, native_module_imports: BTreeSet::new(), + uses_data_url_dynamic_import: false, uses_fetch: false, uses_crypto_builtins: false, uses_zlib_brotli: false, diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 2f74be5bf8..e8704a9ead 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3015 entries across 137 modules. +Total: 3017 entries across 137 modules. ## Modules @@ -1752,6 +1752,8 @@ Total: 3015 entries across 137 modules. - `on` — instance *(class: `HttpServer`)* - `on` — instance *(class: `IncomingMessage`)* - `on` — instance *(class: `ServerResponse`)* +- `once` — instance *(class: `IncomingMessage`)* +- `once` — instance *(class: `ClientRequest`)* - `pause` — instance *(class: `IncomingMessage`)* - `protocol` — instance *(class: `Agent`)* - `read` — instance *(class: `IncomingMessage`)* diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index fb94ea3754..8a3237de38 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -259,6 +259,12 @@ "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." }, + { + "file": "crates/perry-runtime/src/os/signal.rs", + "name": "SIGNAL_ASYNC_IDS", + "verdict": "not_a_gc_pointer", + "why": "Maps owned Rust signal-name strings to async_hooks async IDs. Async IDs are scalar u64 identifiers, not JS heap addresses; the AsyncResource objects themselves live in async_hooks RESOURCES, whose registered scanner visits them." + }, { "file": "crates/perry-runtime/src/perf_hooks.rs", "name": "LOOP_START_MS", @@ -343,6 +349,12 @@ "verdict": "not_a_gc_pointer", "why": "Keyed by decoder handle id; DecoderState is Rust-owned (encoding enum, label, flags), no JS values." }, + { + "file": "crates/perry-runtime/src/timer.rs", + "name": "TIMER_HANDLE_KINDS", + "verdict": "not_a_gc_pointer", + "why": "Maps scalar timer handle IDs to the CallbackTimerKind enum so clearTimeout/clearInterval can destroy the matching async_hooks resource. Neither the i64 keys nor the enum values contain a JS heap address." + }, { "file": "crates/perry-runtime/src/thread.rs", "name": "ACTIVE_THREAD_JOBS", @@ -442,12 +454,24 @@ "verdict": "not_a_gc_pointer", "why": "Transform writable id -> COUNT of queued write jobs (#6607). No address." }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "CURRENT_WORKER_ID", + "verdict": "not_a_gc_pointer", + "why": "Thread-local scalar ID of the currently executing in-process Worker. It is an index into the Rust-owned WORKERS registry, not a JS heap address." + }, { "file": "crates/perry-stdlib/src/worker_threads.rs", "name": "NEXT_PORT_ID", "verdict": "not_a_gc_pointer", "why": "Monotonic MessagePort id counter." }, + { + "file": "crates/perry-stdlib/src/worker_threads.rs", + "name": "NEXT_WORKER_ID", + "verdict": "not_a_gc_pointer", + "why": "Monotonic u64 counter used to allocate in-process Worker registry IDs. It contains no address." + }, { "file": "crates/perry-stdlib/src/ws.rs", "name": "NEXT_WS_ID", 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/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt index 5cf9203a48..e1d0738d7b 100644 --- a/scripts/raw_handle_debt_files.txt +++ b/scripts/raw_handle_debt_files.txt @@ -124,7 +124,7 @@ 9 crates/perry-runtime/src/promise/microtasks.rs 1 crates/perry-runtime/src/promise/native_async.rs 3 crates/perry-runtime/src/promise/rejection.rs -8 crates/perry-runtime/src/promise/then.rs +3 crates/perry-runtime/src/promise/then.rs 8 crates/perry-runtime/src/proxy.rs 6 crates/perry-runtime/src/regex.rs 19 crates/perry-runtime/src/regex/exec_array.rs diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index 84320b2308..27bcefbbfb 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -12,7 +12,7 @@ inline-offset | perry-ext-nodemailer | 1 inline-offset | perry-ext-pg | 2 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 -inline-offset | perry-runtime | 365 +inline-offset | perry-runtime | 364 inline-offset | perry-stdlib | 48 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 diff --git a/scripts/thread_local_cold_allowlist.json b/scripts/thread_local_cold_allowlist.json index 3b945f66f6..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": 227, + "_hot_declarations": 263, "files": { "crates/perry-runtime/src/agent.rs": 1, "crates/perry-runtime/src/arena/block.rs": 2, @@ -24,7 +24,6 @@ "crates/perry-runtime/src/eh_walker.rs": 1, "crates/perry-runtime/src/error.rs": 2, "crates/perry-runtime/src/event_pump.rs": 1, - "crates/perry-runtime/src/fs/callbacks.rs": 1, "crates/perry-runtime/src/fs/dir_glob_watch/watch.rs": 1, "crates/perry-runtime/src/fs/filehandle.rs": 1, "crates/perry-runtime/src/fs/mod.rs": 1,