diff --git a/changelog.d/8671-async-hooks-parity.md b/changelog.d/8671-async-hooks-parity.md new file mode 100644 index 0000000000..8cf5e7227a --- /dev/null +++ b/changelog.d/8671-async-hooks-parity.md @@ -0,0 +1,16 @@ +### 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. + + Native crypto completion callbacks (`pbkdf2`, `scrypt`, `hkdf`, `argon2`, + `generateKey`/`generateKeyPair`, `generatePrime`, `checkPrime`, `randomInt`, + `randomFill`) are now delivered on the check phase inside their own provider + resource instead of being invoked inline, matching Node's threadpool + completions and joining the async `randomBytes` form. The scheduled + completion is a ref'd event-loop handle, so `js_callback_timer_has_pending` + keeps the loop alive until it fires. 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..42f62ef709 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -12,8 +12,9 @@ use crate::nanbox::{double_literal, POINTER_MASK_I64}; 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. @@ -826,6 +827,83 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { )?; return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } + if parent_name.as_str() == "EventEmitterAsyncResource" { + let mut lowered = Vec::with_capacity(super_args.len()); + for arg in super_args { + lowered.push(lower_expr(ctx, arg)?); + } + let options = lowered.first().cloned().unwrap_or_else(|| { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }); + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; + lower_event_emitter_async_resource_subclass_init(ctx, &this_box, &options); + bind_derived_this_after_super(ctx); + let current_class_name = + ctx.class_stack.last().cloned().unwrap_or_default(); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } + if parent_name.as_str() == "AsyncLocalStorage" { + for arg in super_args { + 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 mut lowered = Vec::with_capacity(super_args.len()); + for arg in super_args { + lowered.push(lower_expr(ctx, arg)?); + } + let type_value = lowered.first().cloned().unwrap_or_else(|| undef.clone()); + let options = lowered.get(1).cloned().unwrap_or_else(|| undef.clone()); + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => undef, + }; + ctx.block().call( + DOUBLE, + "js_async_resource_subclass_init", + &[ + (DOUBLE, &this_box), + (DOUBLE, &type_value), + (DOUBLE, &options), + ], + ); + bind_derived_this_after_super(ctx); + let current_class_name = + ctx.class_stack.last().cloned().unwrap_or_default(); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } // `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..cd1a27c466 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -391,6 +391,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 +441,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 +544,15 @@ 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_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 +568,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 diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 5dca17baa9..637ec3b3a2 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -133,6 +133,32 @@ 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. + for arg in args { + let _ = lower_expr(ctx, arg)?; + } + 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])); + Ok(Some(ctx.block().call(DOUBLE, runtime, &[(I64, "0")]))) + } "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..2da650f54b 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_on", + 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..38f960482d 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_on", + 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/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..3863c15c99 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,10 @@ 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, }; const MIN_HEAP_POINTER: u64 = 0x1000; @@ -111,6 +115,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 +128,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 +173,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 +195,14 @@ 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_enter(async_id: u64); + fn js_async_hooks_provider_leave(async_id: u64); } /// #3072: validate an EventEmitter listener argument, returning the closure @@ -271,6 +291,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 +318,7 @@ impl EventEmitterHandle { max_listeners: 10.0, capture_rejections: false, domain_handle: None, + async_resource_handle: 0, } } @@ -463,6 +485,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 +518,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 +974,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). /// @@ -1179,6 +1297,10 @@ pub unsafe extern "C" fn js_event_emitter_emit( let Some(event_name) = event_name_from_bits(event_bits) else { return TAG_FALSE_F64; }; + let async_id = event_emitter_async_id(handle); + if async_id != 0 { + js_async_hooks_provider_enter(async_id); + } let mut had_listeners = false; let mut domain_error: Option<(Handle, f64)> = None; let mut throw_error: Option = None; @@ -1232,16 +1354,23 @@ pub unsafe extern "C" fn js_event_emitter_emit( } if let Some((domain, error)) = domain_error { let _ = js_domain_emit_error(domain, error, nanbox_pointer_bits(handle), false); + if async_id != 0 { + js_async_hooks_provider_leave(async_id); + } return TAG_FALSE_F64; } if let Some(error) = throw_error { js_throw(error); } - if had_listeners { + let result = if had_listeners { TAG_TRUE_F64 } else { TAG_FALSE_F64 + }; + if async_id != 0 { + js_async_hooks_provider_leave(async_id); } + result } /// `emitter.emit(eventName)` — no-args variant. @@ -1260,6 +1389,10 @@ pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) let Some(event_name) = event_name_from_bits(event_bits) else { return TAG_FALSE_F64; }; + let async_id = event_emitter_async_id(handle); + if async_id != 0 { + js_async_hooks_provider_enter(async_id); + } let mut had_listeners = false; let mut domain_error: Option<(Handle, f64)> = None; let mut throw_error: Option = None; @@ -1312,16 +1445,23 @@ pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) } if let Some((domain, error)) = domain_error { let _ = js_domain_emit_error(domain, error, nanbox_pointer_bits(handle), false); + if async_id != 0 { + js_async_hooks_provider_leave(async_id); + } return TAG_FALSE_F64; } if let Some(error) = throw_error { js_throw(error); } - if had_listeners { + let result = if had_listeners { TAG_TRUE_F64 } else { TAG_FALSE_F64 + }; + if async_id != 0 { + js_async_hooks_provider_leave(async_id); } + result } /// `emitter.removeListener(event, listener)`. Removes the most recently added @@ -1809,146 +1949,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..24e38bb39d 100644 --- a/crates/perry-ext-events/src/module_iterators.rs +++ b/crates/perry-ext-events/src/module_iterators.rs @@ -132,32 +132,138 @@ 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_ITER_SHAPE_ID: u32 = 0x7FFF_FF60; + +pub(super) unsafe fn events_on_state_new() -> *mut ArrayHeader { + let scope = TransientRootScope::enter(); + let state = js_array_alloc(6); + let state_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()); + 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, + handle: Handle, + listener: *mut RawClosureHeader, +) { + events_on_state_set(state, EVENTS_ON_HANDLE, handle as f64); + events_on_state_set( + state, + EVENTS_ON_LISTENER, + nanbox_pointer_bits(listener as i64), + ); +} + +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 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 +271,148 @@ 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 handle = f64::from_bits(js_array_get(state, EVENTS_ON_HANDLE).bits()); + let listener = f64::from_bits(js_array_get(state, EVENTS_ON_LISTENER).bits()); + if handle.is_finite() && listener.to_bits() != TAG_UNDEFINED_F64_BITS { + if let Some(emitter) = get_event_emitter_mut(handle as Handle) { + remove_listener_by_callback(emitter, (listener.to_bits() & POINTER_MASK) as i64); + } + } + 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 +434,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..c6b7ff789e --- /dev/null +++ b/crates/perry-ext-events/src/module_on.rs @@ -0,0 +1,207 @@ +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 = + event_helper_target(target_value).unwrap_or_else(|| throw_invalid_emitter(target_value)); + 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_ptr as i64) else { + return (queue_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader; + }; + let event_name_ptr = string_header_ptr_from_arg(event_name_ptr); + let 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 handle = 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 + } + 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 + } + 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_root.get()); + let _ = js_node_stream_method_on(handle, event, listener_value); + } + handle + } + }; + events_on_state_set_target( + (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader, + handle, + listener_root.get() as *mut RawClosureHeader, + ); + 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..44f1767e9a 100644 --- a/crates/perry-ext-events/src/tests.rs +++ b/crates/perry-ext-events/src/tests.rs @@ -15,6 +15,13 @@ 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. Force evacuation + // for the serialized GC-test window; the policy may otherwise choose + // a valid non-moving collection under unit-test pressure. + // + // SAFETY: `GC_TEST_LOCK` is held for the guard's whole lifetime. + unsafe { std::env::set_var("PERRY_GC_FORCE_EVACUATE", "1") }; perry_runtime::gc::js_gc_write_barriers_emitted(1); let frame = perry_runtime::gc::js_shadow_frame_push(0); Self { frame, _lock: lock } @@ -25,6 +32,8 @@ impl Drop for GcTestGuard { fn drop(&mut self) { perry_runtime::gc::js_shadow_frame_pop(self.frame); perry_runtime::gc::js_gc_write_barriers_emitted(0); + // SAFETY: still under `GC_TEST_LOCK` (dropped after this body). + unsafe { std::env::remove_var("PERRY_GC_FORCE_EVACUATE") }; } } @@ -181,6 +190,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/lib.rs b/crates/perry-ext-http/src/lib.rs index a17307bd91..c020357abb 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,44 @@ 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 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). @@ -1833,142 +1878,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/server.rs b/crates/perry-ext-http/src/server/server.rs index 0aa207bd5f..16af91d477 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,6 +776,8 @@ pub unsafe extern "C" fn js_node_http_server_listen(server_handle: i64, args_arr .host .unwrap_or_else(|| extract_host(opts_f64, "0.0.0.0")); let callback = parsed.callback; + let server_async_id = + crate::js_async_hooks_provider_init(b"TCPSERVERWRAP".as_ptr(), b"TCPSERVERWRAP".len()); let (request_tx, request_rx) = mpsc::channel::(1024); let (upgrade_tx, upgrade_rx) = mpsc::channel::(256); @@ -938,6 +942,7 @@ pub unsafe extern "C" fn js_node_http_server_listen(server_handle: i64, args_arr // "Cannot read properties of undefined". The pump fires both with // `this` bound to the server (#2132), via `drain_deferred_listen_events`. if let Some(s) = get_handle_mut::(server_handle) { + s.async_id = server_async_id; queue_deferred_listening_emit(s, callback); } @@ -1479,7 +1484,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 +1671,15 @@ pub(crate) fn try_recv_pending_nonblocking(server_handle: i64) -> Option &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,10 +64,13 @@ where } } } - snapshot + (snapshot, s.async_id) } None => return 0, }; + if async_id != 0 { + unsafe { crate::js_async_hooks_provider_enter(async_id) }; + } let this_val = handle_to_pointer_f64(server_handle); let mut fired = 0i32; // #8082: the drained snapshot crosses each callback — root it. @@ -87,6 +90,9 @@ where fired += 1; } } + if async_id != 0 { + unsafe { crate::js_async_hooks_provider_leave(async_id) }; + } fired } 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..8ecfd7b829 100644 --- a/crates/perry-ext-net/src/dispatch.rs +++ b/crates/perry-ext-net/src/dispatch.rs @@ -207,11 +207,33 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option // #5021 — call the DISTINCT, twin-free symbol directly so the // write reaches ext-net's registry regardless of link order. crate::js_ext_net_socket_write(handle, args[0].to_bits() as i64); + if let Some(callback) = args.iter().copied().skip(1).find(|value| { + extern "C" { + fn js_value_is_closure(value_bits: i64) -> i32; + } + js_value_is_closure(value.to_bits() as i64) != 0 + }) { + let raw = unbox_to_i64(callback) as *const RawClosureHeader; + if !raw.is_null() { + let _ = JsClosure::from_raw(raw).call0(); + } + } undefined() } "end" => { let chunk = args.first().copied().unwrap_or_else(undefined); crate::js_ext_net_socket_end(handle, chunk.to_bits() as i64); + if let Some(callback) = args.iter().copied().find(|value| { + extern "C" { + fn js_value_is_closure(value_bits: i64) -> i32; + } + js_value_is_closure(value.to_bits() as i64) != 0 + }) { + let raw = unbox_to_i64(callback) as *const RawClosureHeader; + if !raw.is_null() { + let _ = JsClosure::from_raw(raw).call0(); + } + } 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..df109d4016 --- /dev/null +++ b/crates/perry-ext-net/src/gc_roots.rs @@ -0,0 +1,66 @@ +//! 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); + } + } + } + } + // #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..4594c3459b 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -40,7 +40,6 @@ use perry_ffi::{ }; 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, @@ -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. @@ -400,6 +356,10 @@ enum PendingNetEvent { /// so without this the connection lifecycle never completes and the /// test hangs. End(i64), + /// Completion of the writable-side shutdown requested by `socket.end()`. + /// This is distinct from `End`, which is the peer's readable-side FIN and + /// fires the public `end` event. + ShutdownComplete(i64), Close(i64), Error(i64, String), AbortError(i64), @@ -435,6 +395,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 +430,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 +544,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 +597,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 +623,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, @@ -723,6 +680,7 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, .unwrap_or_else(|| "0.0.0.0".to_string()); (port as u16, host) }; + let server_async_id = init_provider(b"TCPSERVERWRAP"); let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); @@ -737,6 +695,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 +901,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 +971,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 +993,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 +1066,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 +1160,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 +1208,72 @@ 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)) => { + if let Err(e) = t.write_all(&bytes).await { + push_event(PendingNetEvent::Error(id, format!("{}", e))); + break; + } + } + Some(SocketCommand::End) => { + let _ = t.shutdown().await; + writable_ended = true; + push_event(PendingNetEvent::ShutdownComplete(id)); + } + 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)); + } + push_event(PendingNetEvent::Close(id)); mark_closed(id); break; } @@ -1284,7 +1300,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); @@ -1312,12 +1337,17 @@ pub(crate) async fn run_socket_task( } Some(SocketCommand::End) => { let _ = t.shutdown().await; + writable_ended = true; + push_event(PendingNetEvent::ShutdownComplete(id)); } 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 +1610,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,6 +1765,7 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { drop(frame); lifecycle::drain_once_listeners(id, "end"); } + PendingNetEvent::ShutdownComplete(_) => {} PendingNetEvent::Close(id) => { extern "C" { fn js_tls_client_record_closed(handle: i64); @@ -1739,6 +1804,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 +1833,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 +1936,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 +1957,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..9875eccaf0 100644 --- a/crates/perry-ext-net/src/lifecycle.rs +++ b/crates/perry-ext-net/src/lifecycle.rs @@ -20,7 +20,10 @@ //! `NativeModSig` rows live in //! `perry-codegen/src/lower_call/native_table/net_events.rs`. -use perry_ffi::{alloc_string, nanbox_string_bits, ArrayHeader, JsValue, StringHeader}; +use perry_ffi::{ + alloc_string, nanbox_string_bits, ArrayHeader, JsClosure, JsValue, RawClosureHeader, + StringHeader, +}; use std::collections::HashSet; use crate::statics; @@ -315,6 +318,34 @@ pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { js_ext_net_socket_write(handle, chunk_bits); } +unsafe fn call_socket_completion(values: [f64; 3]) { + extern "C" { + fn js_value_is_closure(value_bits: i64) -> i32; + } + if let Some(callback) = values + .into_iter() + .find(|value| js_value_is_closure(value.to_bits() as i64) != 0) + { + const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + let raw = (callback.to_bits() & POINTER_MASK) as *const RawClosureHeader; + if !raw.is_null() { + let _ = JsClosure::from_raw(raw).call0(); + } + } +} + +/// 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, +) { + js_ext_net_socket_write(handle, chunk.to_bits() as i64); + call_socket_completion([chunk, encoding_or_callback, callback]); +} + /// `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,6 +361,17 @@ 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) { + 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)) { @@ -353,6 +395,18 @@ 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, +) { + js_ext_net_socket_end(handle, chunk_or_callback.to_bits() as i64); + call_socket_completion([chunk_or_callback, encoding_or_callback, callback]); +} + /// `socket.destroy()` — hard close. Flags the handle destroyed (so /// `socket.destroyed` / `readyState` reflect it) and sends the teardown /// command. @@ -443,6 +497,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 +516,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..b6c848e8d3 --- /dev/null +++ b/crates/perry-ext-net/src/provider_lifecycle.rs @@ -0,0 +1,107 @@ +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::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/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..f67aaaa771 100644 --- a/crates/perry-ext-zlib/src/stream.rs +++ b/crates/perry-ext-zlib/src/stream.rs @@ -66,6 +66,9 @@ 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_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 +470,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 +565,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 +582,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), @@ -708,11 +715,12 @@ 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()); statics() .lock() .unwrap() .pending - .push_back(ZlibEvent::OneShotCallback(callback, result)); + .push_back(ZlibEvent::OneShotCallback(callback, result, async_id)); notify_main_thread(); } @@ -872,6 +880,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 +974,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 +1336,10 @@ 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); + 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 +1367,13 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { } } } + ZlibEvent::Finish(id) => { + for cb in listeners_for(id, "finish") { + 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 +1399,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 +1413,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); } @@ -1397,8 +1428,12 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); } } - ZlibEvent::OneShotCallback(cb, result) => { + ZlibEvent::OneShotCallback(cb, result, async_id) => { + js_async_hooks_provider_enter(async_id); + js_async_hooks_provider_leave(async_id); + js_async_hooks_provider_enter(async_id); call_one_shot_callback(cb, result); + js_async_hooks_provider_leave(async_id); } ZlibEvent::Error(id, msg) => { let err_f64 = build_error_object(&msg); @@ -1410,6 +1445,9 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 { drop_buffered_stream(&mut statics().lock().unwrap(), id); } } + if event_async_id != 0 { + js_async_hooks_provider_leave(event_async_id); + } } count } @@ -1625,6 +1663,7 @@ mod stream_tests { fn no_consumer_state() -> ZlibStreamState { ZlibStreamState { + async_id: 0, codec: Codec::Gzip, level: Compression::default(), codec_state: None, @@ -1652,6 +1691,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..53567fcdd0 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,28 @@ 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) + }); + 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 +328,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 +347,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 +455,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..9b4ddc0e52 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -49,6 +49,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 +156,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 +170,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 +186,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 +262,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 +356,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 +532,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 +572,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 +603,69 @@ 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() { - f(callback); - } - }); - } - guard.set(false); + 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(); + for callback in rooted { + callback.with_const_ptr::(|callback| { + if !callback.is_null() { + f(callback); + } + }); + } + 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); + } + } +} + +/// 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 +691,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 +819,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 +835,141 @@ 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); +} + +/// C ABI used by separately-linked native providers such as perry-ext-zlib. +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64 { + if type_ptr.is_null() { + return 0; + } + let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len)); + let resource = crate::object::js_object_alloc_null_proto(0, 0); + init_resource( + type_name, + crate::value::js_nanbox_pointer(resource as i64), + true, + ) + .async_id +} + +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_provider_init_with_trigger( + type_ptr: *const u8, + type_len: usize, + trigger_async_id: u64, +) -> u64 { + if type_ptr.is_null() { + return 0; + } + let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len)); + let resource = crate::object::js_object_alloc_null_proto(0, 0); + init_resource_with_trigger( + type_name, + crate::value::js_nanbox_pointer(resource as i64), + true, + trigger_async_id, + ) + .async_id +} + +#[no_mangle] +pub extern "C" fn js_async_hooks_provider_enter(async_id: u64) { + let trigger_async_id = RESOURCES + .lock() + .unwrap() + .get(&async_id) + .map(|meta| meta.trigger_async_id) + .unwrap_or(0); + enter_resource_scope(AsyncResourceIds { + async_id, + trigger_async_id, + }); +} + +#[no_mangle] +pub extern "C" fn js_async_hooks_provider_leave(async_id: u64) { + leave_resource_scope(async_id); +} + +#[no_mangle] +pub extern "C" fn js_async_hooks_provider_destroy(async_id: u64) { + destroy(async_id); +} + pub fn enqueue_gc_destroy(async_id: u64) { if async_id != 0 { GC_DESTROY_QUEUE.lock().unwrap().push_back(async_id); @@ -756,7 +1003,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 +1161,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 +1254,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 +1476,7 @@ pub fn try_async_resource_method_dispatch( ) { return None; } - if !ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) { - return None; - } + let handle = resolve_async_resource_handle(receiver)?; let scope = crate::gc::RuntimeHandleScope::new(); let raw_args: Vec = if args_ptr.is_null() || args_len == 0 { Vec::new() @@ -1049,7 +1490,7 @@ pub fn try_async_resource_method_dispatch( "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 +1535,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 +1567,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 +1606,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 +1640,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 +1686,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 +1755,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 +1898,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 +1960,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 +1980,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/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..ef4136a0e1 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,24 @@ pub(super) struct CpExecPending { static CP_LIVE: Mutex>> = Mutex::new(None); +fn cp_init_async_resources( + cp: f64, + stdin_obj: f64, + 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 pipe_ids = [ + crate::async_hooks::init_resource("PIPEWRAP", stdin_obj, true), + 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 +507,7 @@ fn cp_register_live_child_parts( for (_, stream, _) in &extra_pipes { cp_set_field(*stream, b"__cpHandle", handle_f); } + let (process_ids, pipe_ids) = cp_init_async_resources(cp, stdin_obj, stdout_obj, stderr_obj); // For fork, keep a clone of the IPC socket for send/disconnect; the reader // thread owns the original. @@ -504,6 +526,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 +1120,8 @@ pub(super) fn cp_exec_async( exceeded: false, timed_out: false, }); + let (process_ids, pipe_ids) = + cp_init_async_resources(cp, TAG_NULL_F64, stdout_obj, stderr_obj); { let mut guard = cp_live_lock(); @@ -1099,6 +1130,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 +1541,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 +1564,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 +1584,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 +1603,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 +1624,23 @@ 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() + .position(|bits| *bits == target_bits) + .map(|index| child.pipe_ids[index]) +} + // ============================================================================ // Live `stdin.write()` / `kill()` — called from the mod.rs method bodies. // ============================================================================ @@ -1855,133 +1920,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..76a9fa7293 100644 --- a/crates/perry-runtime/src/dns.rs +++ b/crates/perry-runtime/src/dns.rs @@ -985,12 +985,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 +1151,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 { @@ -1264,5 +1258,16 @@ fn resolver_object(initial_servers: Vec) -> *mut ObjectHeader { for method in RESOLVER_RESOLVE_METHODS { js_object_set_field_by_name(obj, key(method), method_value(method)); } - obj + let scope = crate::gc::RuntimeHandleScope::new(); + let obj = scope.root_raw_mut_ptr(obj); + let (_, obj_ptr) = obj.across_mut::(|| { + obj.with_mut_ptr::(|obj_ptr| { + let _ = crate::async_hooks::init_resource( + "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..fe239d503c 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() } 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..905cf6cae6 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)] @@ -589,6 +590,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 { @@ -1461,6 +1463,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 +1490,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 +1530,7 @@ pub extern "C" fn js_fs_watch_file(path_value: f64, arg1: f64, arg2: f64) -> f64 } let id = next_watch_id(); let object_value = build_stat_watcher_object(id); + let _ = crate::async_hooks::init_resource("STATWATCHER", object_value, true); let 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") }; @@ -1683,8 +1688,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 +1701,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/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..015ccea408 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,49 @@ 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 = crate::object::js_object_alloc_null_proto(0, 1); + let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); + crate::object::js_object_set_field_by_name(namespace, key, value.get_nanbox_f64()); + Some(js_nanbox_pointer(namespace as i64)) +} + /// 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..6f8588d421 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -94,6 +94,69 @@ 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 async_options = if options_value.is_any_string() { + f64::from_bits(crate::value::TAG_UNDEFINED) + } else { + options_handle.get_nanbox_f64() + }; + let resource = crate::async_hooks::js_async_resource_new(name, async_options); + let 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..f8b370a622 100644 --- a/crates/perry-runtime/src/node_stream_constructors/pipeline.rs +++ b/crates/perry-runtime/src/node_stream_constructors/pipeline.rs @@ -221,6 +221,12 @@ 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); diff --git a/crates/perry-runtime/src/node_stream_dispatch.rs b/crates/perry-runtime/src/node_stream_dispatch.rs index d846a52dd5..913fcb8e9e 100644 --- a/crates/perry-runtime/src/node_stream_dispatch.rs +++ b/crates/perry-runtime/src/node_stream_dispatch.rs @@ -257,6 +257,201 @@ 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 bits = receiver.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 = hidden_key(EVENT_EMITTER_ASYNC_RESOURCE_KEY); + let value = 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) { + 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); + js_object_set_field_by_name( + proto, + hidden_key(name.as_bytes()), + f64::from_bits(JSValue::pointer(closure as *const u8).bits()), + ); + crate::object::set_builtin_property_attrs( + proto as usize, + name.to_string(), + crate::object::PropertyAttrs::new(true, false, true), + ); + }; + 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 key = hidden_key(name.as_bytes()); + js_object_set_field_by_name(proto, key, f64::from_bits(crate::value::TAG_UNDEFINED)); + crate::object::set_builtin_accessor_descriptor( + proto as usize, + name.to_string(), + crate::object::AccessorDescriptor { + get: JSValue::pointer(closure as *const u8).bits(), + set: 0, + }, + crate::object::PropertyAttrs::new(true, false, true), + ); + } +} + +pub(crate) unsafe fn install_event_emitter_async_resource_instance_methods( + obj: *mut ObjectHeader, + this_value: f64, +) { + 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.to_bits() as i64); + js_object_set_field_by_name( + obj, + hidden_key(name.as_bytes()), + f64::from_bits(JSValue::pointer(closure as *const u8).bits()), + ); + } + + // 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.to_bits() as i64); + crate::closure::js_closure_set_capture_ptr(closure, 1, operation); + let key = hidden_key(name.as_bytes()); + js_object_set_field_by_name(obj, key, f64::from_bits(crate::value::TAG_UNDEFINED)); + crate::object::set_builtin_accessor_descriptor( + obj as usize, + name.to_string(), + crate::object::AccessorDescriptor { + get: JSValue::pointer(closure as *const u8).bits(), + set: 0, + }, + crate::object::PropertyAttrs::new(true, false, true), + ); + } +} + +#[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..abf3d92f4e 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,19 @@ 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 promise = crate::fs::promise_value_fs(handle); + let ids = crate::async_hooks::init_resource("FILEHANDLE", handle, 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 + } + 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 +153,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 +213,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 +397,13 @@ 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 _ = crate::async_hooks::init_resource("DIRHANDLE", directory, true); + crate::fs::promise_value_fs(directory) + } + 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..b0927a44e9 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() + || (raw >= crate::value::addr_class::HANDLE_BAND_MAX + && 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 + || (raw >= crate::value::addr_class::HANDLE_BAND_MAX + && 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..12c923bd97 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,13 @@ 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 msg = crate::string::js_string_from_bytes(message.as_ptr(), message.len() as u32); + let err = 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..122aa716d7 100644 --- a/crates/perry-runtime/src/promise/assimilate.rs +++ b/crates/perry-runtime/src/promise/assimilate.rs @@ -278,6 +278,8 @@ extern "C" fn native_promise_adoption_job(closure: *const crate::closure::Closur is_error, capture_context(), std::ptr::null_mut(), + unsafe { (*outer).async_id }, + unsafe { (*outer).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..3286ac0d4d 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,25 @@ 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) } + }; crate::r#box::retain_async_box_activation(trap.box_activation); TASK_QUEUE.with(|q| { q.borrow_mut().push_back(Task::AsyncStep( @@ -638,6 +687,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..db67ddb45a 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,9 +401,20 @@ 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)); + crate::async_hooks::before_promise(async_id, trigger_async_id); if !(*promise).next.is_null() { if is_fulfilled { js_promise_resolve((*promise).next, value); @@ -407,6 +422,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { js_promise_reject((*promise).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 +484,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,6 +594,17 @@ 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 @@ -584,6 +618,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; @@ -630,6 +665,7 @@ fn run_microtasks(mode: MicrotaskDrainMode) -> i32 { 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 +744,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 @@ -901,16 +939,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..1601cc99b7 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 @@ -1740,6 +1767,8 @@ extern "C" fn finally_passthrough_fulfill( false, capture_context(), std::ptr::null_mut(), + unsafe { (*next).async_id }, + unsafe { (*next).trigger_async_id }, )); }); crate::event_pump::js_notify_promise_progress(); @@ -1766,6 +1795,8 @@ extern "C" fn finally_passthrough_reject( true, capture_context(), std::ptr::null_mut(), + unsafe { (*next).async_id }, + unsafe { (*next).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..ef88bdd357 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1069,7 +1069,33 @@ 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 { + if let Some(handle) = async_resource_handle_from_value(target) { + if unsafe { crate::symbol::js_is_symbol(key) } != 0 { + unsafe { crate::symbol::js_object_set_symbol_property(target, key, value) }; + return Some(true); + } + let Some(name) = key_to_rust_string(key) else { + return Some(false); + }; + crate::object::handle_expando::handle_expando_set(handle, &name, value); + return Some(true); + } + let handle = small_handle_from_value(target)?; let Some(name) = key_to_rust_string(key) else { // A SYMBOL-keyed write on a small native handle (e.g. the @@ -1403,8 +1429,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..0697c30bf1 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,42 @@ 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) { + TIMER_HANDLE_KINDS.lock().unwrap().insert(id, kind); +} + +/// Synthetic constructor object for `Timeout`/`Immediate` native handles. +/// Timer ids outlive queue removal, so the kind table intentionally retains +/// the entry after clear/fire just as Node retains the wrapper's prototype. +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 +744,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 +770,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 +1029,7 @@ pub extern "C" fn js_set_timeout_callback(callback: i64, delay_ms: f64) -> i64 { Vec::new(), "Timeout", CallbackTimerKind::Timeout, + None, ) } @@ -999,6 +1041,7 @@ pub extern "C" fn js_set_immediate_callback(callback: i64) -> i64 { Vec::new(), "Immediate", CallbackTimerKind::Immediate, + None, ) } @@ -1008,6 +1051,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 +1067,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 +1132,7 @@ pub unsafe extern "C" fn js_set_timeout_callback_args( args, "Timeout", CallbackTimerKind::Timeout, + None, ) } @@ -1098,6 +1153,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 +1582,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..56dc0c90cc 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 @@ -84,6 +83,86 @@ impl AsyncLocalStorageHandle { } } +fn throw_invalid_receiver() -> ! { + let message = b"Value of \"this\" must be of type AsyncLocalStorage"; + 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); + 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 key = perry_runtime::string::js_string_from_bytes( + SUBCLASS_BACKING_KEY.as_ptr(), + SUBCLASS_BACKING_KEY.len() as u32, + ); + let value = perry_runtime::object::js_object_get_field_by_name_f64( + raw as *const perry_runtime::object::ObjectHeader, + 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 current_this = this_handle.get_nanbox_f64(); + let raw = perry_runtime::value::js_nanbox_get_pointer(current_this) + as *mut perry_runtime::object::ObjectHeader; + if !raw.is_null() + && perry_runtime::value::addr_class::is_above_handle_band(raw as usize) + && perry_runtime::value::addr_class::is_valid_obj_ptr(raw as *const u8) + { + let key = perry_runtime::string::js_string_from_bytes( + SUBCLASS_BACKING_KEY.as_ptr(), + SUBCLASS_BACKING_KEY.len() as u32, + ); + perry_runtime::object::js_object_set_field_by_name( + raw, + key, + perry_runtime::value::js_nanbox_pointer(backing), + ); + 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 = + 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, + value_handle.get_nanbox_f64(), + ); + } + } + this_handle.get_nanbox_f64() +} + /// Create a new AsyncLocalStorage instance /// Returns a handle (i64) #[no_mangle] @@ -97,7 +176,7 @@ 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, @@ -105,6 +184,8 @@ pub unsafe extern "C" fn js_async_local_storage_run( // Validate before mutating the async context so an invalid callback throws // without leaving a pushed store behind (#3092). let cb = validate_callback(callback); + 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 @@ -119,18 +200,17 @@ 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() { +pub extern "C" fn js_async_local_storage_enter_with(receiver: Handle, store: f64) { + if let Some(handle) = resolve_async_local_storage_handle(receiver) { unsafe { js_async_context_als_enter_with(handle, store) }; } } @@ -141,24 +221,20 @@ 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 { // 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 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); - if guarded { - js_async_context_als_scope_leave(); - } + js_async_context_als_scope_leave(); result } @@ -166,8 +242,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..48605b8c3e 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,88 @@ 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 receiver = perry_runtime::object::js_implicit_this_get(); + 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) 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; + } + + // 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 +142,7 @@ pub(crate) unsafe fn dispatch_async_local_storage_method( ) { return None; } - if get_handle_mut::(handle).is_none() { - return None; - } + let handle = crate::async_local_storage::resolve_async_local_storage_handle(handle)?; Some(match method { "getStore" => crate::async_local_storage::js_async_local_storage_get_store(handle), "run" if args.len() >= 2 => { @@ -119,31 +198,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 +277,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 +317,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..c72f963591 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; } @@ -176,7 +179,7 @@ pub(super) unsafe fn dispatch_client_incoming_method( } self_ref } - "on" | "addListener" if args.len() >= 2 => { + "on" | "once" | "addListener" if args.len() >= 2 => { let event = (args[0].to_bits() & PTR_MASK) as *const perry_runtime::StringHeader; let callback = (args[1].to_bits() & PTR_MASK) as i64; unsafe { 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..8e9d0f94d5 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()) } @@ -822,7 +859,8 @@ mod tests { // `"pbkdf2"` / 2-arg `"randomBytes"` arm, so the call returned `undefined` // and the Node-style `(err, value)` callback never fired — the awaiting // (promisified) Promise hung forever. These tests assert the callback IS - // invoked, with a null error and a non-null Buffer result. + // invoked — on the check phase, the way Node's threadpool completions are — + // with a null error and a non-null Buffer result. use std::cell::Cell; thread_local! { static CB_FIRED: Cell = const { Cell::new(false) }; @@ -882,6 +920,26 @@ mod tests { unsafe { js_crypto_native_dispatch(method.as_ptr(), method.len(), args.as_ptr(), args.len()); } + // Node runs `crypto.pbkdf2` on the libuv threadpool, so its `(err, key)` + // callback is NEVER invoked synchronously — it lands on the check phase, + // exactly like the async `randomBytes` form below (#6430). Perry derives + // the key eagerly but must still defer delivery, because the callback has + // to execute inside its own `PBKDF2REQUEST` async resource so + // `async_hooks` observes init -> before -> after -> destroy around it + // (`test-parity/node-suite/async_hooks/hooks/provider-pbkdf2-lifecycle.ts`). + assert!( + !CB_FIRED.with(|f| f.get()), + "pbkdf2 callback must not fire synchronously" + ); + // The scheduled completion must keep the event loop alive, otherwise a + // real caller (`util.promisify(crypto.pbkdf2)`) would exit before the + // callback ran and the awaiting Promise would never settle. + assert_ne!( + perry_runtime::timer::js_callback_timer_has_pending(), + 0, + "pbkdf2 completion must be a pending, ref'd event-loop handle" + ); + 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..114cddbf5f --- /dev/null +++ b/crates/perry-stdlib/src/tls/event_pump.rs @@ -0,0 +1,163 @@ +//! 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() + }; + 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 +} 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..235483ca7d 100644 --- a/crates/perry-stdlib/src/webcrypto/digest.rs +++ b/crates/perry-stdlib/src/webcrypto/digest.rs @@ -60,6 +60,6 @@ pub unsafe extern "C" fn js_webcrypto_digest(algo_bits: f64, data_bits: f64) -> perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value.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); + perry_runtime::timer::schedule_native_callback(cl as i64, &[], "HASHREQUEST"); promise } diff --git a/crates/perry-stdlib/src/webcrypto/hmac.rs b/crates/perry-stdlib/src/webcrypto/hmac.rs index 2cc75ec999..ac7b6fc395 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 @@ -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..258a90bebb 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,11 +950,35 @@ pub(super) unsafe fn resolve_with_bytes(bytes: &[u8]) -> *mut Promise { resolve_with_bits(val) } +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(b: bool) -> *mut Promise { let bits = if b { TAG_TRUE } else { TAG_FALSE }; resolve_with_bits(bits) } +pub(super) unsafe fn resolve_with_bool_provider( + b: bool, + provider_type: &'static str, +) -> *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 { match algo { HashAlgo::Sha1 => Sha1::digest(data).to_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..814b3407a2 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,9 +234,13 @@ 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) }; + for resource in async_resources { + perry_runtime::async_hooks::enter_resource_scope(resource); + } + // Web-style `addEventListener` listeners receive a `MessageEvent` wrapper // (with `.data`) for "message" events; Node-style `on` listeners receive the // raw payload. Lazily build the event object only if a web listener exists. @@ -289,4 +305,7 @@ fn dispatch_worker_event(worker_id: u64, event: &str, arg: Option) { perry_runtime::closure::js_closure_call0(closure); } } + for resource in async_resources.into_iter().rev() { + perry_runtime::async_hooks::leave_resource_scope(resource.async_id); + } } diff --git a/crates/perry-stdlib/src/zlib.rs b/crates/perry-stdlib/src/zlib.rs index ccade48bd6..415b037903 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,16 @@ 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); + } match ev { ZlibEvent::Data(id, bytes) => { publish_zlib_bytes_written(id); @@ -1369,7 +1401,9 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { js_closure_call0(cb as *const ClosureHeader); } } - ZlibEvent::OneShotCallback(cb, result) => { + ZlibEvent::OneShotCallback(cb, result, ids) => { + 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 +1432,7 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { } } } + perry_runtime::async_hooks::leave_resource_scope(ids.async_id); } ZlibEvent::Error(id, msg) => { let err_f64 = build_zlib_error(&msg); @@ -1410,6 +1445,9 @@ pub unsafe extern "C" fn js_zlib_process_pending() -> i32 { ZLIB_STREAMS.lock().unwrap().remove(&id); } } + if let Some(ids) = event_ids { + perry_runtime::async_hooks::leave_resource_scope(ids.async_id); + } } count } diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 5ca8b4c2db..ffa282300a 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -49,6 +49,9 @@ 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_CONST_ARRAY_DESCRIPTOR", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_INLINE_SHADOW_SLOT", "PERRY_DISABLE_BUFFER_FAST_PATH", @@ -139,6 +142,8 @@ 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. + "PERRY_DIALECT_DUMP", // `opt_report`'s own module doc states the contract this exclusion rests // on: "Observational only. Nothing in this module is read by codegen … // the returned fact sets are bit-identical with the report on and off, diff --git a/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_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..566ab8a28c 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": 259, "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,