diff --git a/Cargo.lock b/Cargo.lock
index 6389c5f135..9f570d3c17 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6164,6 +6164,15 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "perry-ext-qs"
+version = "0.5.1519"
+dependencies = [
+ "perry-ffi",
+ "perry-runtime",
+ "serde_json",
+]
+
[[package]]
name = "perry-ext-ratelimit"
version = "0.5.1519"
@@ -6332,6 +6341,7 @@ dependencies = [
"ryu",
"serde",
"serde_json",
+ "sha2 0.11.0",
"socket2",
"taffy",
"temporal_rs",
diff --git a/Cargo.toml b/Cargo.toml
index e890c61a5a..08061d929f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,7 @@ members = [
"crates/perry-ext-events",
"crates/perry-ext-decimal",
"crates/perry-ext-dayjs",
+ "crates/perry-ext-qs",
"crates/perry-ext-moment",
"crates/perry-ext-cheerio",
"crates/perry-ext-sharp",
@@ -491,6 +492,7 @@ perry-ext-axios = { path = "crates/perry-ext-axios" }
perry-ext-events = { path = "crates/perry-ext-events" }
perry-ext-decimal = { path = "crates/perry-ext-decimal" }
perry-ext-dayjs = { path = "crates/perry-ext-dayjs" }
+perry-ext-qs = { path = "crates/perry-ext-qs" }
perry-ext-moment = { path = "crates/perry-ext-moment" }
perry-ext-cheerio = { path = "crates/perry-ext-cheerio" }
perry-ext-sharp = { path = "crates/perry-ext-sharp" }
diff --git a/changelog.d/8751-native-qs.md b/changelog.d/8751-native-qs.md
new file mode 100644
index 0000000000..bce298a0a7
--- /dev/null
+++ b/changelog.d/8751-native-qs.md
@@ -0,0 +1 @@
+**Native `qs` compatibility:** bundle nested query-string parsing and serialization so Stripe request encoding no longer compiles the AOT-hostile `get-intrinsic` dependency chain.
diff --git a/changelog.d/8813-map-ordered-delete.md b/changelog.d/8813-map-ordered-delete.md
new file mode 100644
index 0000000000..392760d82c
--- /dev/null
+++ b/changelog.d/8813-map-ordered-delete.md
@@ -0,0 +1 @@
+Ordered `Map.delete` now compacts surviving entries with one overlap-safe move and repairs numeric, string, and pointer side-index offsets in place instead of barrier-storing and rehashing every survivor. Insertion order, SameValueZero lookup, delete-then-readd ordering, moving-GC pointer-index rebuilds, and old-to-young external-slot tracking are preserved.
diff --git a/changelog.d/8816-runtime-library-build-stamp.md b/changelog.d/8816-runtime-library-build-stamp.md
new file mode 100644
index 0000000000..4946d68fab
--- /dev/null
+++ b/changelog.d/8816-runtime-library-build-stamp.md
@@ -0,0 +1,4 @@
+Fixed stale or mismatched `libperry_runtime` archives passing `perry doctor` and
+then failing during native linking with undefined runtime symbols. Runtime
+archives now carry a compiler build identity that `perry doctor` and compile
+pipelines verify before linking, with actionable rebuild and reinstall guidance.
diff --git a/changelog.d/8817-compiled-package-builtin-import.md b/changelog.d/8817-compiled-package-builtin-import.md
new file mode 100644
index 0000000000..b62df17cf3
--- /dev/null
+++ b/changelog.d/8817-compiled-package-builtin-import.md
@@ -0,0 +1,6 @@
+Added regression coverage for Node builtin named imports used from natively
+compiled dependencies. The exact `@hono/node-server` fallback from
+`options.createServer` to its module-scope `http.createServer` import now has an
+offline compiler fixture and a real-package listen/fetch/close release smoke,
+covering both `http` and `node:http` spellings without relying on app-level
+imports.
diff --git a/changelog.d/8818-sharp-create.md b/changelog.d/8818-sharp-create.md
new file mode 100644
index 0000000000..43924da192
--- /dev/null
+++ b/changelog.d/8818-sharp-create.md
@@ -0,0 +1 @@
+fix(sharp): support object-form `create` inputs with solid RGB or RGBA backgrounds, so `sharp({ create: ... })` can encode images instead of failing with an invalid handle.
diff --git a/changelog.d/8820-call-return-array-stores.md b/changelog.d/8820-call-return-array-stores.md
new file mode 100644
index 0000000000..2e1c50ac40
--- /dev/null
+++ b/changelog.d/8820-call-return-array-stores.md
@@ -0,0 +1 @@
+Array index assignments whose base is a call expression now preserve the call's statically known Array type and use the typed array-store path while evaluating the base exactly once. Strict writes through that path also honor non-writable and accessor descriptors, read-only length, and non-extensible holes.
diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs
index c099a601eb..370e208959 100644
--- a/crates/perry-api-manifest/src/entries.rs
+++ b/crates/perry-api-manifest/src/entries.rs
@@ -33,6 +33,7 @@ pub const NATIVE_MODULES: &[&str] = &[
"mysql2/promise", // mysql2's promise-API subpath
"pg", // PostgreSQL client
"uuid", // RFC-4122 UUID generation
+ "qs", // nested query-string parser/stringifier (Stripe dependency)
"bcrypt", // bcrypt password hashing (replaces the N-API addon)
"argon2", // Argon2 password hashing (replaces the N-API addon)
"ioredis", // Redis/Valkey client
diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs
index 3894bbfa36..01f196dc7c 100644
--- a/crates/perry-api-manifest/src/entries/part_4.rs
+++ b/crates/perry-api-manifest/src/entries/part_4.rs
@@ -1114,4 +1114,23 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[
property("bun", "stdin"),
property("bun", "stdout"),
property("bun", "stderr"),
+ // --- qs (issue #8751) ---
+ // Native nested query-string codec. This keeps Stripe's request encoder
+ // off qs' legacy get-intrinsic/ES-shims dependency chain.
+ method_sig(
+ "qs",
+ "stringify",
+ false,
+ None,
+ &[p_any("value"), p_any("options")],
+ TypeSpec::String,
+ ),
+ method_sig(
+ "qs",
+ "parse",
+ false,
+ None,
+ &[p_str("input"), p_any("options")],
+ TypeSpec::Any,
+ ),
];
diff --git a/crates/perry-codegen/src/expr/call_return_array_index_tests.rs b/crates/perry-codegen/src/expr/call_return_array_index_tests.rs
new file mode 100644
index 0000000000..68802a99f7
--- /dev/null
+++ b/crates/perry-codegen/src/expr/call_return_array_index_tests.rs
@@ -0,0 +1,175 @@
+use crate::{compile_module, CompileOptions};
+use perry_hir::types::Type;
+use perry_hir::{Class, Expr, Function, Module, Param, Stmt};
+
+fn param(id: u32, name: &str, ty: Type) -> Param {
+ Param {
+ id,
+ name: name.to_string(),
+ ty,
+ default: None,
+ decorators: Vec::new(),
+ is_rest: false,
+ arguments_object: None,
+ }
+}
+
+fn function(
+ id: u32,
+ name: &str,
+ params: Vec,
+ return_type: Type,
+ body: Vec,
+) -> Function {
+ Function {
+ id,
+ name: name.to_string(),
+ type_params: Vec::new(),
+ params,
+ return_type,
+ body,
+ is_async: false,
+ is_generator: false,
+ is_strict: true,
+ is_exported: false,
+ captures: Vec::new(),
+ decorators: Vec::new(),
+ was_plain_async: false,
+ was_unrolled: false,
+ }
+}
+
+fn call_get_data(selector: i64) -> Expr {
+ Expr::Call {
+ callee: Box::new(Expr::PropertyGet {
+ object: Box::new(Expr::This),
+ property: "getData".to_string(),
+ byte_offset: 0,
+ }),
+ args: vec![Expr::Integer(selector)],
+ type_args: Vec::new(),
+ byte_offset: 0,
+ }
+}
+
+fn store_class(receiver_selector: i64) -> Class {
+ let get_data = function(
+ 2,
+ "getData",
+ vec![param(3, "selector", Type::Number)],
+ Type::Array(Box::new(Type::Any)),
+ vec![Stmt::Return(Some(Expr::Array(vec![Expr::Number(0.0)])))],
+ );
+ let write = function(
+ 3,
+ "write",
+ vec![
+ param(1, "index", Type::Number),
+ param(2, "value", Type::Any),
+ ],
+ Type::Void,
+ vec![Stmt::Expr(Expr::PutValueSet {
+ target: Box::new(call_get_data(0)),
+ key: Box::new(Expr::LocalGet(1)),
+ value: Box::new(Expr::LocalGet(2)),
+ receiver: Box::new(call_get_data(receiver_selector)),
+ strict: true,
+ })],
+ );
+ Class {
+ id: 1,
+ name: "Store".to_string(),
+ type_params: Vec::new(),
+ extends: None,
+ extends_name: None,
+ native_extends: None,
+ extends_expr: None,
+ heritage_lexically_shadowed: false,
+ fields: Vec::new(),
+ constructor: None,
+ methods: vec![get_data, write],
+ getters: Vec::new(),
+ setters: Vec::new(),
+ static_accessor_names: Vec::new(),
+ static_accessor_fn_ids: Vec::new(),
+ computed_members: Vec::new(),
+ static_fields: Vec::new(),
+ static_methods: Vec::new(),
+ decorators: Vec::new(),
+ is_exported: false,
+ aliases: Vec::new(),
+ is_nested: false,
+ alloc_width_hint: 0,
+ specialized_from: None,
+ }
+}
+
+fn compile_store_ir(receiver_selector: i64) -> String {
+ let mut module = Module::new("call_return_array_put_value.ts");
+ module.classes.push(store_class(receiver_selector));
+ let bytes = compile_module(
+ &module,
+ CompileOptions {
+ emit_ir_only: true,
+ ..Default::default()
+ },
+ )
+ .expect("call-returned array store compiles");
+ String::from_utf8(bytes).expect("LLVM IR is UTF-8")
+}
+
+fn write_method_ir(ir: &str) -> &str {
+ let signature = "define double @perry_method_call_return_array_put_value_ts__Store__write(";
+ let start = ir.find(signature).expect("write method is present in IR");
+ let method_and_rest = &ir[start..];
+ let end = method_and_rest
+ .find("\n}\n")
+ .expect("write method has a closing brace");
+ &method_and_rest[..end + 3]
+}
+
+#[test]
+fn same_call_returned_array_uses_array_index_store_and_evaluates_receiver_once() {
+ let ir = compile_store_ir(0);
+ let write_ir = write_method_ir(&ir);
+
+ assert!(
+ write_ir.contains("call i64 @js_typed_feedback_array_set_index_or_string("),
+ "a call with an Array return type must use the array-index semantic fallback:\n{write_ir}"
+ );
+ assert!(
+ !write_ir.contains("call double @js_put_value_set_dyn_ic("),
+ "the proven array receiver must not enter the generic Proxy-compatible PutValue ladder:\n{write_ir}"
+ );
+ assert_eq!(
+ write_ir
+ .matches(
+ "call double @perry_method_call_return_array_put_value_ts__Store__getData("
+ )
+ .count(),
+ 1,
+ "the syntactically duplicated target/receiver call represents one evaluated assignment base"
+ );
+}
+
+#[test]
+fn distinct_call_receiver_stays_on_explicit_receiver_put_value_path() {
+ let ir = compile_store_ir(1);
+ let write_ir = write_method_ir(&ir);
+
+ assert!(
+ !write_ir.contains("call i64 @js_typed_feedback_array_set_index_or_string("),
+ "a receiver that differs from the target must not use same-receiver array lowering:\n{write_ir}"
+ );
+ assert!(
+ write_ir.contains("call double @js_put_value_set("),
+ "the distinct receiver must be passed to the generic PutValue helper:\n{write_ir}"
+ );
+ assert_eq!(
+ write_ir
+ .matches("call double @perry_method_call_return_array_put_value_ts__Store__getData(")
+ .count(),
+ 2,
+ "target and distinct receiver calls are independently evaluated"
+ );
+}
diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs
index dbfed78b52..5cb84eb1b5 100644
--- a/crates/perry-codegen/src/expr/mod.rs
+++ b/crates/perry-codegen/src/expr/mod.rs
@@ -161,6 +161,8 @@ mod write_pic_barrier_tests;
// temp alloca through the same shadow-slot emission every named local uses,
// and it now lives outside `crate::expr`.
#[cfg(test)]
+mod call_return_array_index_tests;
+#[cfg(test)]
mod call_spread_rooting_tests;
mod call_spread_short;
#[cfg(test)]
diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs
index 8e0a0450c5..0d3bca3bc4 100644
--- a/crates/perry-codegen/src/expr/proxy_reflect.rs
+++ b/crates/perry-codegen/src/expr/proxy_reflect.rs
@@ -1260,7 +1260,15 @@ fn is_numeric_string_key(key: &str) -> bool {
}
fn put_value_index_fast_path(ctx: &FnCtx<'_>, target: &Expr, key: &Expr, receiver: &Expr) -> bool {
- if !same_side_effect_free_receiver(target, receiver) {
+ // `PutValueSet` stores the assignment base in both `target` and `receiver`;
+ // those two HIR trees describe one source evaluation, not two evaluations
+ // that may be coalesced only when pure. Use the same structural-identity
+ // check as the generic same-receiver PutValue lowering below so expressions
+ // such as `this.getData()[index] = value` can retain the statically known
+ // Array type. `IndexSet::lower` evaluates that base once. A genuinely
+ // distinct receiver (including a call with different arguments) still
+ // fails closed to the explicit-receiver runtime path.
+ if !same_put_value_receiver_expr(target, receiver) {
return false;
}
if is_array_expr(ctx, target) {
diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs
index 42f62ef709..ad8b47a3a8 100644
--- a/crates/perry-codegen/src/expr/this_super_call.rs
+++ b/crates/perry-codegen/src/expr/this_super_call.rs
@@ -9,6 +9,7 @@ use perry_hir::Expr;
use crate::lower_call::{bind_inline_constructor_params, restore_inline_constructor_scope};
use crate::nanbox::{double_literal, POINTER_MASK_I64};
+use crate::rooting::{self, Repr};
use crate::types::{DOUBLE, I1, I32, I64, PTR};
use super::{
@@ -257,6 +258,67 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result {
Some(slot) => ctx.block().load(DOUBLE, &slot),
None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)),
};
+ let async_parent = ctx
+ .classes
+ .get(¤t_class_name)
+ .and_then(|class| class.extends_name.clone());
+ if matches!(
+ async_parent.as_deref(),
+ Some("EventEmitterAsyncResource" | "AsyncLocalStorage" | "AsyncResource")
+ ) {
+ let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
+ let zero_idx = "0".to_string();
+ let one_idx = "1".to_string();
+ let first =
+ ctx.block()
+ .call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &zero_idx)]);
+ let second =
+ ctx.block()
+ .call(DOUBLE, "js_array_get_f64", &[(I64, &arr), (I32, &one_idx)]);
+ rooting::with_rooted_group(ctx, 3, |ctx, group| {
+ let this_root = group.adopt_emitted(ctx, Repr::Boxed, &this_box, true);
+ let first_root = group.adopt_emitted(ctx, Repr::Boxed, &first, true);
+ let second_root = group.adopt_emitted(ctx, Repr::Boxed, &second, true);
+ let this_box = group.reread_emitted(ctx, this_root);
+ match async_parent.as_deref() {
+ Some("EventEmitterAsyncResource") => {
+ let options = group.reread_emitted(ctx, first_root);
+ lower_event_emitter_async_resource_subclass_init(
+ ctx, &this_box, &options,
+ );
+ }
+ Some("AsyncLocalStorage") => {
+ ctx.block().call(
+ DOUBLE,
+ "js_async_local_storage_subclass_init",
+ &[(DOUBLE, &this_box)],
+ );
+ }
+ Some("AsyncResource") => {
+ let type_value = group.reread_emitted(ctx, first_root);
+ let options = group.reread_emitted(ctx, second_root);
+ ctx.block().call(
+ DOUBLE,
+ "js_async_resource_subclass_init",
+ &[
+ (DOUBLE, &this_box),
+ (DOUBLE, &type_value),
+ (DOUBLE, &options),
+ ],
+ );
+ }
+ _ => unreachable!(),
+ }
+ bind_derived_this_after_super(ctx);
+ crate::lower_call::apply_field_initializers_recursive(
+ ctx,
+ ¤t_class_name,
+ crate::lower_call::FieldInitMode::SelfOnly,
+ )?;
+ Ok(undef.clone())
+ })?;
+ return Ok(undef);
+ }
// `class X extends Map | Set` with a spread super (`super(...args)`,
// e.g. NestJS's `ModulesContainer`'s `super(...arguments)`) — install
// the hidden collection backing from the (possibly spread) args
@@ -828,27 +890,28 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result {
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
if parent_name.as_str() == "EventEmitterAsyncResource" {
- let mut lowered = Vec::with_capacity(super_args.len());
- for arg in super_args {
- lowered.push(lower_expr(ctx, arg)?);
- }
- let options = lowered.first().cloned().unwrap_or_else(|| {
- double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
+ let operands: Vec<_> = super_args.iter().collect();
+ return rooting::with_operands_rooted(ctx, &operands, |ctx, lowered| {
+ let options = lowered.first().cloned().unwrap_or_else(|| {
+ double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
+ });
+ let this_box = match ctx.this_stack.last().cloned() {
+ Some(slot) => ctx.block().load(DOUBLE, &slot),
+ None => {
+ double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
+ }
+ };
+ lower_event_emitter_async_resource_subclass_init(
+ ctx, &this_box, &options,
+ );
+ bind_derived_this_after_super(ctx);
+ crate::lower_call::apply_field_initializers_recursive(
+ ctx,
+ ¤t_class_name,
+ crate::lower_call::FieldInitMode::SelfOnly,
+ )?;
+ Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)))
});
- let this_box = match ctx.this_stack.last().cloned() {
- Some(slot) => ctx.block().load(DOUBLE, &slot),
- None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)),
- };
- lower_event_emitter_async_resource_subclass_init(ctx, &this_box, &options);
- bind_derived_this_after_super(ctx);
- let current_class_name =
- ctx.class_stack.last().cloned().unwrap_or_default();
- crate::lower_call::apply_field_initializers_recursive(
- ctx,
- ¤t_class_name,
- crate::lower_call::FieldInitMode::SelfOnly,
- )?;
- return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
if parent_name.as_str() == "AsyncLocalStorage" {
for arg in super_args {
@@ -875,34 +938,32 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result {
}
if parent_name.as_str() == "AsyncResource" {
let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
- let mut lowered = Vec::with_capacity(super_args.len());
- for arg in super_args {
- lowered.push(lower_expr(ctx, arg)?);
- }
- let type_value = lowered.first().cloned().unwrap_or_else(|| undef.clone());
- let options = lowered.get(1).cloned().unwrap_or_else(|| undef.clone());
- let this_box = match ctx.this_stack.last().cloned() {
- Some(slot) => ctx.block().load(DOUBLE, &slot),
- None => undef,
- };
- ctx.block().call(
- DOUBLE,
- "js_async_resource_subclass_init",
- &[
- (DOUBLE, &this_box),
- (DOUBLE, &type_value),
- (DOUBLE, &options),
- ],
- );
- bind_derived_this_after_super(ctx);
- let current_class_name =
- ctx.class_stack.last().cloned().unwrap_or_default();
- crate::lower_call::apply_field_initializers_recursive(
- ctx,
- ¤t_class_name,
- crate::lower_call::FieldInitMode::SelfOnly,
- )?;
- return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
+ let operands: Vec<_> = super_args.iter().collect();
+ return rooting::with_operands_rooted(ctx, &operands, |ctx, lowered| {
+ let type_value =
+ lowered.first().cloned().unwrap_or_else(|| undef.clone());
+ let options = lowered.get(1).cloned().unwrap_or_else(|| undef.clone());
+ let this_box = match ctx.this_stack.last().cloned() {
+ Some(slot) => ctx.block().load(DOUBLE, &slot),
+ None => undef.clone(),
+ };
+ ctx.block().call(
+ DOUBLE,
+ "js_async_resource_subclass_init",
+ &[
+ (DOUBLE, &this_box),
+ (DOUBLE, &type_value),
+ (DOUBLE, &options),
+ ],
+ );
+ bind_derived_this_after_super(ctx);
+ crate::lower_call::apply_field_initializers_recursive(
+ ctx,
+ ¤t_class_name,
+ crate::lower_call::FieldInitMode::SelfOnly,
+ )?;
+ Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)))
+ });
}
// `class X extends Request` / `extends Response`:
// `super(input, init)` allocates the underlying native
diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs
index cd1a27c466..37dfea14a3 100644
--- a/crates/perry-codegen/src/ext_registry.rs
+++ b/crates/perry-codegen/src/ext_registry.rs
@@ -220,6 +220,7 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[
("js_https_request", OwnerKind::WellKnown("http")),
("js_https_get", OwnerKind::WellKnown("http")),
("js_http_on", OwnerKind::WellKnown("http")),
+ ("js_http_once", OwnerKind::WellKnown("http")),
("js_http_set_header", OwnerKind::WellKnown("http")),
("js_http_set_timeout", OwnerKind::WellKnown("http")),
("js_http_set_timeout_full", OwnerKind::WellKnown("http")),
@@ -300,6 +301,7 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[
("js_node_http_server_ref", OwnerKind::WellKnown("http")),
("js_node_http_server_unref", OwnerKind::WellKnown("http")),
("js_node_http_im_on", OwnerKind::WellKnown("http")),
+ ("js_node_http_im_once", OwnerKind::WellKnown("http")),
("js_node_http_im_pause", OwnerKind::WellKnown("http")),
("js_node_http_im_resume", OwnerKind::WellKnown("http")),
("js_node_http_im_pause_self", OwnerKind::WellKnown("http")),
@@ -549,6 +551,8 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[
("js_event_emitter_new", OwnerKind::WellKnown("events")),
("js_event_emitter_new_with_options", OwnerKind::WellKnown("events")),
("js_event_emitter_async_resource_new", OwnerKind::WellKnown("events")),
+ ("js_event_emitter_async_resource_call", OwnerKind::WellKnown("events")),
+ ("js_event_emitter_async_resource_subclass_init", OwnerKind::WellKnown("events")),
("js_event_emitter_async_resource_async_id", OwnerKind::WellKnown("events")),
("js_event_emitter_async_resource_trigger_async_id", OwnerKind::WellKnown("events")),
("js_event_emitter_async_resource_async_resource", OwnerKind::WellKnown("events")),
@@ -639,6 +643,7 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[
("js_node_forge_", "node-forge"),
// Native runtime TypeScript transpilation subset (#8511).
("js_typescript_", "typescript"),
+ ("js_qs_", "qs"),
];
/// Process-wide collector of provider keys observed during codegen.
@@ -1126,6 +1131,8 @@ mod tests {
"js_event_emitter_set_max_listeners",
"js_event_emitter_get_max_listeners",
"js_event_emitter_domain_value",
+ "js_event_emitter_async_resource_call",
+ "js_event_emitter_async_resource_subclass_init",
] {
assert_symbol_routes_to(symbol, OwnerKind::WellKnown("events"));
}
@@ -1172,6 +1179,8 @@ mod tests {
("js_node_forge_create_certificate", "node-forge"),
("js_parcel_watcher_subscribe", "@parcel/watcher"),
("js_parcel_watcher_get_events_since", "@parcel/watcher"),
+ ("js_qs_stringify", "qs"),
+ ("js_qs_parse", "qs"),
] {
assert_symbol_routes_to(symbol, OwnerKind::WellKnown(binding));
}
diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs
index 637ec3b3a2..6e3e29f1ba 100644
--- a/crates/perry-codegen/src/lower_call/builtin.rs
+++ b/crates/perry-codegen/src/lower_call/builtin.rs
@@ -145,9 +145,14 @@ pub(super) fn lower_builtin_new<'a>(
// the native-module call table used by `dns.Resolver()`. Route it
// to the same runtime constructor and preserve evaluation of any
// superfluous arguments.
- for arg in args {
+ let options_idx = adopt_optional_arg(ctx, args, 0, group)?;
+ for arg in args.iter().skip(1) {
let _ = lower_expr(ctx, arg)?;
}
+ let options = match options_idx {
+ Some(index) => group.reread(ctx, index)?,
+ None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)),
+ };
let runtime = if import_src.is_some_and(|source| {
source.strip_prefix("node:").unwrap_or(source) == "dns/promises"
}) {
@@ -157,7 +162,18 @@ pub(super) fn lower_builtin_new<'a>(
};
ctx.pending_declares
.push((runtime.to_string(), DOUBLE, vec![I64]));
- Ok(Some(ctx.block().call(DOUBLE, runtime, &[(I64, "0")])))
+ let zero = "0".to_string();
+ let args_array = ctx.block().call(I64, "js_array_alloc", &[(I32, &zero)]);
+ let args_array = ctx.block().call(
+ I64,
+ "js_array_push_f64",
+ &[(I64, &args_array), (DOUBLE, &options)],
+ );
+ Ok(Some(ctx.block().call(
+ DOUBLE,
+ runtime,
+ &[(I64, &args_array)],
+ )))
}
"Utf8Stream"
if import_src
diff --git a/crates/perry-codegen/src/lower_call/native_table/http_client.rs b/crates/perry-codegen/src/lower_call/native_table/http_client.rs
index 2da650f54b..eb43c085f1 100644
--- a/crates/perry-codegen/src/lower_call/native_table/http_client.rs
+++ b/crates/perry-codegen/src/lower_call/native_table/http_client.rs
@@ -141,7 +141,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[
has_receiver: true,
method: "once",
class_filter: Some("ClientRequest"),
- runtime: "js_http_on",
+ runtime: "js_http_once",
args: &[NA_STR, NA_PTR],
ret: NR_PTR,
},
diff --git a/crates/perry-codegen/src/lower_call/native_table/http_server.rs b/crates/perry-codegen/src/lower_call/native_table/http_server.rs
index 38f960482d..dc044db30a 100644
--- a/crates/perry-codegen/src/lower_call/native_table/http_server.rs
+++ b/crates/perry-codegen/src/lower_call/native_table/http_server.rs
@@ -391,7 +391,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[
has_receiver: true,
method: "once",
class_filter: Some("IncomingMessage"),
- runtime: "js_node_http_im_on",
+ runtime: "js_node_http_im_once",
args: &[NA_STR, NA_PTR],
ret: NR_F64,
},
diff --git a/crates/perry-codegen/src/lower_call/native_table/media.rs b/crates/perry-codegen/src/lower_call/native_table/media.rs
index 0364f36ba3..44e1f12310 100644
--- a/crates/perry-codegen/src/lower_call/native_table/media.rs
+++ b/crates/perry-codegen/src/lower_call/native_table/media.rs
@@ -4,9 +4,10 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[
// ========== sharp ==========
// Factory: sharp(path) → js_sharp_from_file. Instance methods take
// Handle (i64), compatible with the has_receiver:true dispatch path.
- // `sharp(input)` accepts a file-path string OR a Buffer/Uint8Array of
- // encoded image bytes. Pass the raw NaN-boxed value (NA_JSV) so
- // `js_sharp_from_input` can branch on the Buffer registry probe.
+ // `sharp(input)` accepts a file-path string, a Buffer/Uint8Array of encoded
+ // image bytes, or a `{ create: { ... } }` descriptor. Pass the raw
+ // NaN-boxed value (NA_JSV) so `js_sharp_from_input` can branch on its
+ // representation.
NativeModSig {
module: "sharp",
has_receiver: false,
diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs
index 3115da4e5a..65ea9392ce 100644
--- a/crates/perry-codegen/src/lower_call/native_table/mod.rs
+++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs
@@ -33,6 +33,7 @@ mod node_dns;
mod node_domain;
mod node_misc;
mod parcel_watcher;
+mod qs;
mod thread_lodash;
mod tls_events;
mod tui;
@@ -178,6 +179,7 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock> = LazyLock::n
v.extend_from_slice(media::MEDIA_ROWS);
v.extend_from_slice(native_profile::NATIVE_PROFILE_ROWS);
v.extend_from_slice(parcel_watcher::PARCEL_WATCHER_ROWS);
+ v.extend_from_slice(qs::QS_ROWS);
v.extend_from_slice(tui::TUI_ROWS);
v.extend_from_slice(typescript::TYPESCRIPT_ROWS);
v.extend_from_slice(yoga::YOGA_ROWS);
diff --git a/crates/perry-codegen/src/lower_call/native_table/qs.rs b/crates/perry-codegen/src/lower_call/native_table/qs.rs
new file mode 100644
index 0000000000..a6fefc9562
--- /dev/null
+++ b/crates/perry-codegen/src/lower_call/native_table/qs.rs
@@ -0,0 +1,22 @@
+use super::*;
+
+pub(super) const QS_ROWS: &[NativeModSig] = &[
+ NativeModSig {
+ module: "qs",
+ has_receiver: false,
+ method: "stringify",
+ class_filter: None,
+ runtime: "js_qs_stringify",
+ args: &[NA_F64, NA_F64],
+ ret: NR_STR,
+ },
+ NativeModSig {
+ module: "qs",
+ has_receiver: false,
+ method: "parse",
+ class_filter: None,
+ runtime: "js_qs_parse",
+ args: &[NA_STR, NA_F64],
+ ret: NR_OBJ_FROM_JSON_STR,
+ },
+];
diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs
index 1b0f9ec8ce..4628dbdfec 100644
--- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs
+++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs
@@ -112,6 +112,7 @@ pub(crate) fn declare_net_http(module: &mut LlModule) {
module.declare_function("js_https_get_overload", I64, &[I64]);
module.declare_function("js_https_request_overload", I64, &[I64]);
module.declare_function("js_http_on", I64, &[I64, I64, I64]);
+ module.declare_function("js_http_once", I64, &[I64, I64, I64]);
module.declare_function("js_http_request", I64, &[DOUBLE, I64]);
module.declare_function("js_http_request_body", I64, &[I64]);
module.declare_function("js_http_request_body_length", DOUBLE, &[I64]);
@@ -222,6 +223,7 @@ pub(crate) fn declare_net_http(module: &mut LlModule) {
module.declare_function("js_node_http_im_resume", VOID, &[I64]);
module.declare_function("js_node_http_im_destroy", VOID, &[I64]);
module.declare_function("js_node_http_im_on", DOUBLE, &[I64, I64, I64]);
+ module.declare_function("js_node_http_im_once", DOUBLE, &[I64, I64, I64]);
module.declare_function("js_node_http_im_read", DOUBLE, &[I64]);
module.declare_function("js_node_http_im_set_timeout", I64, &[I64, DOUBLE, I64]);
// ServerResponse:
diff --git a/crates/perry-ext-events/src/lib.rs b/crates/perry-ext-events/src/lib.rs
index 3863c15c99..cfeb124c79 100644
--- a/crates/perry-ext-events/src/lib.rs
+++ b/crates/perry-ext-events/src/lib.rs
@@ -62,6 +62,7 @@ use module_iterators::{
events_on_queue_listener, events_on_state_new, events_on_state_set_target,
events_once_abort_listener, events_once_event_target_listener,
events_once_stream_reject_listener, events_once_stream_resolve_listener,
+ EVENTS_ON_EVENT_EMITTER, EVENTS_ON_EVENT_TARGET, EVENTS_ON_NET_HANDLE, EVENTS_ON_STREAM,
};
const MIN_HEAP_POINTER: u64 = 0x1000;
@@ -201,8 +202,11 @@ extern "C" {
fn js_async_resource_emit_destroy(handle: i64) -> i64;
fn js_async_resource_set_event_emitter(handle: i64, event_emitter: i64);
fn js_event_emitter_async_resource_subclass_backing(receiver: i64) -> i64;
- fn js_async_hooks_provider_enter(async_id: u64);
- fn js_async_hooks_provider_leave(async_id: u64);
+ fn js_async_hooks_provider_run_catching(
+ async_id: u64,
+ callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64,
+ data: *mut std::ffi::c_void,
+ ) -> f64;
}
/// #3072: validate an EventEmitter listener argument, returning the closure
@@ -1291,16 +1295,47 @@ pub unsafe extern "C" fn js_event_emitter_emit(
handle: Handle,
event_bits: i64,
args_ptr: *mut ArrayHeader,
+) -> f64 {
+ if event_name_from_bits(event_bits).is_none() {
+ return f64::from_bits(0x7FFC_0000_0000_0003);
+ }
+ let async_id = event_emitter_async_id(handle);
+ if async_id == 0 {
+ return js_event_emitter_emit_impl(handle, event_bits, args_ptr);
+ }
+ let mut call = EventEmitterEmitCall {
+ handle,
+ event_bits,
+ args_ptr,
+ };
+ js_async_hooks_provider_run_catching(
+ async_id,
+ event_emitter_emit_thunk,
+ &mut call as *mut EventEmitterEmitCall as *mut std::ffi::c_void,
+ )
+}
+
+struct EventEmitterEmitCall {
+ handle: Handle,
+ event_bits: i64,
+ args_ptr: *mut ArrayHeader,
+}
+
+unsafe extern "C" fn event_emitter_emit_thunk(data: *mut std::ffi::c_void) -> f64 {
+ let call = &mut *(data as *mut EventEmitterEmitCall);
+ js_event_emitter_emit_impl(call.handle, call.event_bits, call.args_ptr)
+}
+
+unsafe fn js_event_emitter_emit_impl(
+ handle: Handle,
+ event_bits: i64,
+ args_ptr: *mut ArrayHeader,
) -> f64 {
const TAG_FALSE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0003);
const TAG_TRUE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0004);
let Some(event_name) = event_name_from_bits(event_bits) else {
return TAG_FALSE_F64;
};
- let async_id = event_emitter_async_id(handle);
- if async_id != 0 {
- js_async_hooks_provider_enter(async_id);
- }
let mut had_listeners = false;
let mut domain_error: Option<(Handle, f64)> = None;
let mut throw_error: Option = None;
@@ -1354,23 +1389,16 @@ pub unsafe extern "C" fn js_event_emitter_emit(
}
if let Some((domain, error)) = domain_error {
let _ = js_domain_emit_error(domain, error, nanbox_pointer_bits(handle), false);
- if async_id != 0 {
- js_async_hooks_provider_leave(async_id);
- }
return TAG_FALSE_F64;
}
if let Some(error) = throw_error {
js_throw(error);
}
- let result = if had_listeners {
+ if had_listeners {
TAG_TRUE_F64
} else {
TAG_FALSE_F64
- };
- if async_id != 0 {
- js_async_hooks_provider_leave(async_id);
}
- result
}
/// `emitter.emit(eventName)` — no-args variant.
@@ -1384,15 +1412,37 @@ pub unsafe extern "C" fn js_event_emitter_emit(
/// `event_name_ptr` must be null or a Perry-runtime `StringHeader`.
#[no_mangle]
pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64) -> f64 {
+ if event_name_from_bits(event_bits).is_none() {
+ return f64::from_bits(0x7FFC_0000_0000_0003);
+ }
+ let async_id = event_emitter_async_id(handle);
+ if async_id == 0 {
+ return js_event_emitter_emit0_impl(handle, event_bits);
+ }
+ let mut call = EventEmitterEmit0Call { handle, event_bits };
+ js_async_hooks_provider_run_catching(
+ async_id,
+ event_emitter_emit0_thunk,
+ &mut call as *mut EventEmitterEmit0Call as *mut std::ffi::c_void,
+ )
+}
+
+struct EventEmitterEmit0Call {
+ handle: Handle,
+ event_bits: i64,
+}
+
+unsafe extern "C" fn event_emitter_emit0_thunk(data: *mut std::ffi::c_void) -> f64 {
+ let call = &mut *(data as *mut EventEmitterEmit0Call);
+ js_event_emitter_emit0_impl(call.handle, call.event_bits)
+}
+
+unsafe fn js_event_emitter_emit0_impl(handle: Handle, event_bits: i64) -> f64 {
const TAG_FALSE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0003);
const TAG_TRUE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0004);
let Some(event_name) = event_name_from_bits(event_bits) else {
return TAG_FALSE_F64;
};
- let async_id = event_emitter_async_id(handle);
- if async_id != 0 {
- js_async_hooks_provider_enter(async_id);
- }
let mut had_listeners = false;
let mut domain_error: Option<(Handle, f64)> = None;
let mut throw_error: Option = None;
@@ -1445,23 +1495,16 @@ pub unsafe extern "C" fn js_event_emitter_emit0(handle: Handle, event_bits: i64)
}
if let Some((domain, error)) = domain_error {
let _ = js_domain_emit_error(domain, error, nanbox_pointer_bits(handle), false);
- if async_id != 0 {
- js_async_hooks_provider_leave(async_id);
- }
return TAG_FALSE_F64;
}
if let Some(error) = throw_error {
js_throw(error);
}
- let result = if had_listeners {
+ if had_listeners {
TAG_TRUE_F64
} else {
TAG_FALSE_F64
- };
- if async_id != 0 {
- js_async_hooks_provider_leave(async_id);
}
- result
}
/// `emitter.removeListener(event, listener)`. Removes the most recently added
diff --git a/crates/perry-ext-events/src/module_iterators.rs b/crates/perry-ext-events/src/module_iterators.rs
index 24e38bb39d..e7584299df 100644
--- a/crates/perry-ext-events/src/module_iterators.rs
+++ b/crates/perry-ext-events/src/module_iterators.rs
@@ -141,11 +141,17 @@ const EVENTS_ON_DONE: u32 = 2;
const EVENTS_ON_ABORT: u32 = 3;
const EVENTS_ON_HANDLE: u32 = 4;
const EVENTS_ON_LISTENER: u32 = 5;
+const EVENTS_ON_EVENT_NAME: u32 = 6;
+const EVENTS_ON_TARGET_KIND: u32 = 7;
+pub(super) const EVENTS_ON_EVENT_EMITTER: u32 = 0;
+pub(super) const EVENTS_ON_EVENT_TARGET: u32 = 1;
+pub(super) const EVENTS_ON_NET_HANDLE: u32 = 2;
+pub(super) const EVENTS_ON_STREAM: u32 = 3;
const EVENTS_ON_ITER_SHAPE_ID: u32 = 0x7FFF_FF60;
pub(super) unsafe fn events_on_state_new() -> *mut ArrayHeader {
let scope = TransientRootScope::enter();
- let state = js_array_alloc(6);
+ let state = js_array_alloc(8);
let state_root = scope.root_nanbox(nanbox_pointer_bits(state as i64));
let buffer = js_array_alloc(0);
let buffer_root = scope.root_nanbox(nanbox_pointer_bits(buffer as i64));
@@ -158,6 +164,8 @@ pub(super) unsafe fn events_on_state_new() -> *mut ArrayHeader {
let _ = js_array_push_f64(state_ptr(), undefined_value());
let _ = js_array_push_f64(state_ptr(), undefined_value());
let _ = js_array_push_f64(state_ptr(), undefined_value());
+ let _ = js_array_push_f64(state_ptr(), undefined_value());
+ let _ = js_array_push_f64(state_ptr(), undefined_value());
state_ptr()
}
@@ -172,15 +180,19 @@ unsafe fn events_on_state_set(state: *mut ArrayHeader, index: u32, value: f64) {
pub(super) unsafe fn events_on_state_set_target(
state: *mut ArrayHeader,
- handle: Handle,
+ target: f64,
listener: *mut RawClosureHeader,
+ event_name: f64,
+ target_kind: u32,
) {
- events_on_state_set(state, EVENTS_ON_HANDLE, handle as f64);
+ events_on_state_set(state, EVENTS_ON_HANDLE, target);
events_on_state_set(
state,
EVENTS_ON_LISTENER,
nanbox_pointer_bits(listener as i64),
);
+ events_on_state_set(state, EVENTS_ON_EVENT_NAME, event_name);
+ events_on_state_set(state, EVENTS_ON_TARGET_KIND, target_kind as f64);
}
fn events_on_iter_result(value: f64, done: bool) -> f64 {
@@ -246,6 +258,12 @@ pub(super) extern "C" fn events_on_queue_listener(
if !state.is_null() {
let scope = TransientRootScope::enter();
let state_root = scope.root_nanbox(nanbox_pointer_bits(state as i64));
+ let current_state = (state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader;
+ if f64::from_bits(js_array_get(current_state, EVENTS_ON_DONE).bits()).to_bits()
+ == TAG_TRUE_F64_BITS
+ {
+ return f64::from_bits(TAG_UNDEFINED_F64_BITS);
+ }
let mut args = js_array_alloc(0);
args = js_array_push_f64(args, arg0);
let args_root = scope.root_nanbox(nanbox_pointer_bits(args as i64));
@@ -307,11 +325,40 @@ extern "C" fn events_on_return(closure: *const RawClosureHeader) -> f64 {
return events_on_resolved(undefined_value(), true);
}
events_on_state_set(state, EVENTS_ON_DONE, f64::from_bits(TAG_TRUE_F64_BITS));
- let handle = f64::from_bits(js_array_get(state, EVENTS_ON_HANDLE).bits());
+ let target = f64::from_bits(js_array_get(state, EVENTS_ON_HANDLE).bits());
let listener = f64::from_bits(js_array_get(state, EVENTS_ON_LISTENER).bits());
- if handle.is_finite() && listener.to_bits() != TAG_UNDEFINED_F64_BITS {
- if let Some(emitter) = get_event_emitter_mut(handle as Handle) {
- remove_listener_by_callback(emitter, (listener.to_bits() & POINTER_MASK) as i64);
+ let event_name = f64::from_bits(js_array_get(state, EVENTS_ON_EVENT_NAME).bits());
+ let target_kind = f64::from_bits(js_array_get(state, EVENTS_ON_TARGET_KIND).bits()) as u32;
+ if listener.to_bits() != TAG_UNDEFINED_F64_BITS {
+ let listener_ptr = (listener.to_bits() & POINTER_MASK) as i64;
+ match target_kind {
+ EVENTS_ON_EVENT_EMITTER => {
+ if let Some(emitter) = get_event_emitter_mut(target as Handle) {
+ remove_listener_by_callback(emitter, listener_ptr);
+ }
+ }
+ EVENTS_ON_EVENT_TARGET => {
+ let target_ptr = (target.to_bits() & POINTER_MASK) as *mut u8;
+ let event_ptr = (event_name.to_bits() & POINTER_MASK) as *const StringHeader;
+ if !target_ptr.is_null() && !event_ptr.is_null() {
+ js_event_target_remove_event_listener(target_ptr, event_ptr, listener_ptr);
+ }
+ }
+ EVENTS_ON_NET_HANDLE => {
+ let _ = call_net_socket_method(
+ target as Handle,
+ "removeListener",
+ &[event_name, listener],
+ );
+ }
+ EVENTS_ON_STREAM => {
+ let _ = js_node_stream_method_remove_listener(
+ target as Handle,
+ event_name,
+ listener,
+ );
+ }
+ _ => {}
}
}
events_on_finish_pending(state, None);
diff --git a/crates/perry-ext-events/src/module_on.rs b/crates/perry-ext-events/src/module_on.rs
index c6b7ff789e..a583696c80 100644
--- a/crates/perry-ext-events/src/module_on.rs
+++ b/crates/perry-ext-events/src/module_on.rs
@@ -8,8 +8,12 @@ pub unsafe extern "C" fn js_events_on(
) -> *mut ArrayHeader {
ensure_gc_scanner_registered();
let root_scope = TransientRootScope::enter();
- let target =
- event_helper_target(target_value).unwrap_or_else(|| throw_invalid_emitter(target_value));
+ let target_root = root_scope.root_nanbox(target_value);
+ let event_name_root = root_scope.root_nanbox(f64::from_bits(nanbox_string_bits(
+ string_header_ptr_from_arg(event_name_ptr) as *mut StringHeader,
+ )));
+ let _ = event_helper_target(target_root.get())
+ .unwrap_or_else(|| throw_invalid_emitter(target_root.get()));
let queue = js_array_alloc(0);
let queue_root = root_scope.root_nanbox(nanbox_pointer_bits(queue as i64));
let state = events_on_state_new();
@@ -18,10 +22,10 @@ pub unsafe extern "C" fn js_events_on(
(queue_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader,
(state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader,
);
- let Some(event_name) = event_name_from_bits(event_name_ptr as i64) else {
+ let Some(event_name) = event_name_from_bits(event_name_root.get().to_bits() as i64) else {
return (queue_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader;
};
- let event_name_ptr = string_header_ptr_from_arg(event_name_ptr);
+ let event_name_ptr = (event_name_root.get().to_bits() & POINTER_MASK) as *const StringHeader;
let signal = options_signal_or_throw(options);
if signal.is_some_and(signal_is_aborted) {
js_throw(js_abort_error_value());
@@ -33,26 +37,32 @@ pub unsafe extern "C" fn js_events_on(
(state_root.get().to_bits() & POINTER_MASK) as i64,
);
let listener_root = root_scope.root_addr(listener as i64);
- let handle = match target {
+ let target = event_helper_target(target_root.get())
+ .unwrap_or_else(|| throw_invalid_emitter(target_root.get()));
+ let (handle, cleanup_target, cleanup_kind) = match target {
EventHelperTarget::EventEmitter(handle) => {
if let Some(emitter) = get_event_emitter_mut(handle) {
emitter.add_listener(handle, &event_name, listener_root.get(), false, false);
}
- handle
+ (handle, handle as f64, EVENTS_ON_EVENT_EMITTER)
}
EventHelperTarget::EventTarget(target) => {
if !event_name_ptr.is_null() {
js_event_target_add_event_listener(target, event_name_ptr, listener_root.get());
}
- target as Handle
+ (
+ target as Handle,
+ nanbox_pointer_bits(target as i64),
+ EVENTS_ON_EVENT_TARGET,
+ )
}
EventHelperTarget::NetSocket(handle) | EventHelperTarget::NativeHandle(handle) => {
if !event_name_ptr.is_null() {
let event = f64::from_bits(nanbox_string_bits(event_name_ptr as *mut StringHeader));
- let listener_value = nanbox_pointer_bits(listener as i64);
+ let listener_value = nanbox_pointer_bits(listener_root.get());
let _ = call_net_socket_method(handle, "on", &[event, listener_value]);
}
- handle
+ (handle, handle as f64, EVENTS_ON_NET_HANDLE)
}
EventHelperTarget::Stream(handle) => {
if !event_name_ptr.is_null() {
@@ -60,13 +70,15 @@ pub unsafe extern "C" fn js_events_on(
let listener_value = nanbox_pointer_bits(listener_root.get());
let _ = js_node_stream_method_on(handle, event, listener_value);
}
- handle
+ (handle, handle as f64, EVENTS_ON_STREAM)
}
};
events_on_state_set_target(
(state_root.get().to_bits() & POINTER_MASK) as *mut ArrayHeader,
- handle,
+ cleanup_target,
listener_root.get() as *mut RawClosureHeader,
+ event_name_root.get(),
+ cleanup_kind,
);
if let Some(close) = get_object_property(options, b"close") {
if js_array_is_array(close).to_bits() == TAG_TRUE_F64_BITS {
diff --git a/crates/perry-ext-events/src/tests.rs b/crates/perry-ext-events/src/tests.rs
index 44f1767e9a..d65797ac45 100644
--- a/crates/perry-ext-events/src/tests.rs
+++ b/crates/perry-ext-events/src/tests.rs
@@ -7,6 +7,7 @@ static GC_TEST_LOCK: Mutex<()> = Mutex::new(());
struct GcTestGuard {
frame: u64,
+ previous_force_evacuation: i32,
_lock: MutexGuard<'static, ()>,
}
@@ -16,15 +17,17 @@ impl GcTestGuard {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
// This test asserts that mutable roots are rewritten, which is only
- // observable when the collector moves its survivors. Force evacuation
- // for the serialized GC-test window; the policy may otherwise choose
- // a valid non-moving collection under unit-test pressure.
- //
- // SAFETY: `GC_TEST_LOCK` is held for the guard's whole lifetime.
- unsafe { std::env::set_var("PERRY_GC_FORCE_EVACUATE", "1") };
+ // observable when the collector moves its survivors. Use the
+ // runtime's thread-local override so unrelated test threads never
+ // observe a process-wide environment mutation.
+ let previous_force_evacuation = perry_runtime::gc::js_gc_force_evacuation_test_override(1);
perry_runtime::gc::js_gc_write_barriers_emitted(1);
let frame = perry_runtime::gc::js_shadow_frame_push(0);
- Self { frame, _lock: lock }
+ Self {
+ frame,
+ previous_force_evacuation,
+ _lock: lock,
+ }
}
}
@@ -32,8 +35,7 @@ impl Drop for GcTestGuard {
fn drop(&mut self) {
perry_runtime::gc::js_shadow_frame_pop(self.frame);
perry_runtime::gc::js_gc_write_barriers_emitted(0);
- // SAFETY: still under `GC_TEST_LOCK` (dropped after this body).
- unsafe { std::env::remove_var("PERRY_GC_FORCE_EVACUATE") };
+ perry_runtime::gc::js_gc_force_evacuation_test_override(self.previous_force_evacuation);
}
}
diff --git a/crates/perry-ext-http/src/client_request_surface.rs b/crates/perry-ext-http/src/client_request_surface.rs
index db87e79fbf..faad8fe08e 100644
--- a/crates/perry-ext-http/src/client_request_surface.rs
+++ b/crates/perry-ext-http/src/client_request_surface.rs
@@ -108,6 +108,30 @@ extern "C" fn client_once_wrapper(closure: *const RawClosureHeader, rest: f64) -
listeners.remove(position);
true
})
+ .or_else(|| {
+ with_handle_mut::(handle, |response| {
+ let Some(listeners) = response.listeners.get_mut(&event) else {
+ return false;
+ };
+ let Some(position) = listeners.iter().rposition(|entry| *entry == wrapper) else {
+ return false;
+ };
+ listeners.remove(position);
+ true
+ })
+ })
+ .or_else(|| {
+ with_handle_mut::(handle, |request| {
+ let Some(listeners) = request.listeners.get_mut(&event) else {
+ return false;
+ };
+ let Some(position) = listeners.iter().rposition(|entry| *entry == wrapper) else {
+ return false;
+ };
+ listeners.remove(position);
+ true
+ })
+ })
.unwrap_or(false);
if !removed || callback == 0 {
return undefined_value();
diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs
index c020357abb..1e357215ab 100644
--- a/crates/perry-ext-http/src/lib.rs
+++ b/crates/perry-ext-http/src/lib.rs
@@ -756,6 +756,13 @@ extern "C" {
fn js_async_hooks_provider_enter(async_id: u64);
fn js_async_hooks_provider_leave(async_id: u64);
fn js_async_hooks_provider_destroy(async_id: u64);
+ fn js_async_hooks_provider_run_catching_with_this(
+ async_id: u64,
+ this_value: f64,
+ destroy_after: i32,
+ callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64,
+ data: *mut std::ffi::c_void,
+ ) -> f64;
}
fn pending_request_handle(event: &PendingHttpEvent) -> Handle {
@@ -1780,6 +1787,44 @@ pub unsafe extern "C" fn js_http_on(
http_on_impl(handle, event_ptr, callback)
}
+/// `req.once(event, cb)` / client `res.once(event, cb)` — register a wrapper
+/// that removes itself before invoking the original callback.
+#[no_mangle]
+pub unsafe extern "C" fn js_http_once(
+ handle: Handle,
+ event_ptr: *const StringHeader,
+ callback: i64,
+) -> Handle {
+ ensure_gc_scanner_registered();
+ let Some(event) = read_str(event_ptr) else {
+ return handle;
+ };
+ if callback == 0 {
+ return handle;
+ }
+ let wrapper =
+ client_request_surface::create_client_once_wrapper(handle, &event, callback, false);
+ let mut matched = false;
+ with_handle_mut::(handle, |request| {
+ request
+ .listeners
+ .entry(event.clone())
+ .or_default()
+ .push(ClientEventListener {
+ callback,
+ raw_wrapper: wrapper,
+ once: true,
+ });
+ matched = true;
+ });
+ if !matched {
+ with_handle_mut::(handle, |response| {
+ response.listeners.entry(event).or_default().push(wrapper);
+ });
+ }
+ handle
+}
+
unsafe fn http_on_impl(handle: Handle, event_ptr: *const StringHeader, callback: i64) -> Handle {
ensure_gc_scanner_registered();
let event = match read_str(event_ptr) {
diff --git a/crates/perry-ext-http/src/server/handle_dispatch.rs b/crates/perry-ext-http/src/server/handle_dispatch.rs
index 398677b020..86ad3ea08b 100644
--- a/crates/perry-ext-http/src/server/handle_dispatch.rs
+++ b/crates/perry-ext-http/src/server/handle_dispatch.rs
@@ -126,6 +126,8 @@ extern "C" {
fn js_node_http_im_resume(handle: i64);
fn js_node_http_im_destroy(handle: i64);
fn js_node_http_im_on(handle: i64, event_name_ptr: *const StringHeader, callback: i64) -> f64;
+ fn js_node_http_im_once(handle: i64, event_name_ptr: *const StringHeader, callback: i64)
+ -> f64;
fn js_node_http_im_set_encoding(handle: i64, encoding_ptr: *const StringHeader) -> i64;
fn js_node_http_im_set_timeout(handle: i64, msecs: f64, callback: i64) -> i64;
fn js_node_http_im_read(handle: i64) -> f64;
@@ -372,6 +374,14 @@ pub unsafe extern "C" fn js_ext_http_server_dispatch_method(
}
self_ref
}
+ "once" if args.len() >= 2 => {
+ let event_ptr = string_arg(args[0]);
+ if event_ptr.is_null() {
+ return self_ref;
+ }
+ js_node_http_im_once(handle, event_ptr, closure_arg(Some(args[1])));
+ self_ref
+ }
"once" if args.len() >= 2 => {
let event =
read_string_header(string_arg(args[0]) as *mut StringHeader).unwrap_or_default();
diff --git a/crates/perry-ext-http/src/server/request.rs b/crates/perry-ext-http/src/server/request.rs
index fbb6e8af5a..7d675a11bc 100644
--- a/crates/perry-ext-http/src/server/request.rs
+++ b/crates/perry-ext-http/src/server/request.rs
@@ -704,6 +704,29 @@ pub unsafe extern "C" fn js_node_http_im_on(
f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK))
}
+/// `IncomingMessage#once` for both server requests and client responses.
+#[no_mangle]
+pub unsafe extern "C" fn js_node_http_im_once(
+ handle: i64,
+ event_name_ptr: *const StringHeader,
+ callback: i64,
+) -> f64 {
+ if get_handle_mut::(handle).is_none() {
+ extern "C" {
+ fn js_http_once(handle: i64, event_ptr: *const StringHeader, callback: i64) -> i64;
+ }
+ let _ = js_http_once(handle, event_name_ptr, callback);
+ return f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK));
+ }
+ let event = read_string_header(event_name_ptr as *mut _).unwrap_or_default();
+ if event.is_empty() || callback == 0 {
+ return f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK));
+ }
+ let wrapper =
+ crate::client_request_surface::create_client_once_wrapper(handle, &event, callback, false);
+ js_node_http_im_on(handle, event_name_ptr, wrapper)
+}
+
/// `req.setEncoding(encoding)` — switch future `'data'` events from Buffer
/// chunks to decoded string chunks. Returns the receiver for chaining.
#[no_mangle]
diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs
index 16af91d477..6cb0a0212b 100644
--- a/crates/perry-ext-http/src/server/server.rs
+++ b/crates/perry-ext-http/src/server/server.rs
@@ -776,9 +776,6 @@ pub unsafe extern "C" fn js_node_http_server_listen(server_handle: i64, args_arr
.host
.unwrap_or_else(|| extract_host(opts_f64, "0.0.0.0"));
let callback = parsed.callback;
- let server_async_id =
- crate::js_async_hooks_provider_init(b"TCPSERVERWRAP".as_ptr(), b"TCPSERVERWRAP".len());
-
let (request_tx, request_rx) = mpsc::channel::(1024);
let (upgrade_tx, upgrade_rx) = mpsc::channel::(256);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
@@ -941,9 +938,16 @@ pub unsafe extern "C" fn js_node_http_server_listen(server_handle: i64, args_arr
// `server`, so `server.address()` inside the callback threw
// "Cannot read properties of undefined". The pump fires both with
// `this` bound to the server (#2132), via `drain_deferred_listen_events`.
+ // Initialize the provider only after every synchronous setup step has
+ // succeeded. Failure returns above therefore cannot leak a resource with
+ // no matching destroy edge.
+ let server_async_id =
+ crate::js_async_hooks_provider_init(b"TCPSERVERWRAP".as_ptr(), b"TCPSERVERWRAP".len());
if let Some(s) = get_handle_mut::(server_handle) {
s.async_id = server_async_id;
queue_deferred_listening_emit(s, callback);
+ } else {
+ crate::js_async_hooks_provider_destroy(server_async_id);
}
// Closes #604 — `listen()` is now non-blocking. The accept loop is
diff --git a/crates/perry-ext-http/src/server/server/deferred_events.rs b/crates/perry-ext-http/src/server/server/deferred_events.rs
index 075b57b59d..0d1cba9b24 100644
--- a/crates/perry-ext-http/src/server/server/deferred_events.rs
+++ b/crates/perry-ext-http/src/server/server/deferred_events.rs
@@ -2,6 +2,29 @@
use super::*;
+struct DeferredCallbacksCall {
+ callbacks: *const perry_ffi::TransientRootedAddr,
+ len: usize,
+}
+
+unsafe extern "C" fn call_deferred_callbacks(data: *mut std::ffi::c_void) -> f64 {
+ let call = &*(data as *const DeferredCallbacksCall);
+ let callbacks = std::slice::from_raw_parts(call.callbacks, call.len);
+ let mut fired = 0;
+ for callback in callbacks {
+ let callback = callback.get();
+ if callback == 0 {
+ continue;
+ }
+ let closure = JsClosure::from_raw(callback as *const RawClosureHeader);
+ if !closure.is_null() {
+ let _ = closure.call0();
+ fired += 1;
+ }
+ }
+ fired as f64
+}
+
/// #4903 — record a pending `'listening'` emit on a server (http / https /
/// http2 all share the `HttpServer` base). Node registers the
/// `listen(port, cb)` callback as a *once* `'listening'` listener inside
@@ -68,32 +91,23 @@ where
}
None => return 0,
};
- if async_id != 0 {
- unsafe { crate::js_async_hooks_provider_enter(async_id) };
- }
let this_val = handle_to_pointer_f64(server_handle);
- let mut fired = 0i32;
// #8082: the drained snapshot crosses each callback — root it.
let scope = perry_ffi::TransientRootScope::enter();
let rooted = scope.root_addrs(&cbs);
- for cb in &rooted {
- let addr = cb.get();
- if addr == 0 {
- continue;
- }
- let raw = addr as *const RawClosureHeader;
- let closure = unsafe { JsClosure::from_raw(raw) };
- if !closure.is_null() {
- with_implicit_this(this_val, || {
- let _ = unsafe { closure.call0() };
- });
- fired += 1;
- }
- }
- if async_id != 0 {
- unsafe { crate::js_async_hooks_provider_leave(async_id) };
+ let mut call = DeferredCallbacksCall {
+ callbacks: rooted.as_ptr(),
+ len: rooted.len(),
+ };
+ unsafe {
+ crate::js_async_hooks_provider_run_catching_with_this(
+ async_id,
+ this_val,
+ 0,
+ call_deferred_callbacks,
+ &mut call as *mut DeferredCallbacksCall as *mut std::ffi::c_void,
+ ) as i32
}
- fired
}
pub(crate) fn drain_deferred_close_for(server_handle: i64, base_of: F) -> i32
@@ -101,7 +115,7 @@ where
T: Send + Sync + 'static,
F: FnOnce(&mut T) -> &mut HttpServer,
{
- let callbacks = match get_handle_mut::(server_handle) {
+ let (callbacks, async_id) = match get_handle_mut::(server_handle) {
Some(server) => {
let base = base_of(server);
if !std::mem::take(&mut base.pending_close_emit) {
@@ -116,28 +130,27 @@ where
}
}
}
- callbacks
+ let async_id = std::mem::take(&mut base.async_id);
+ (callbacks, async_id)
}
None => return 0,
};
let this_value = handle_to_pointer_f64(server_handle);
let scope = perry_ffi::TransientRootScope::enter();
let callbacks = scope.root_addrs(&callbacks);
- let mut fired = 0;
- for callback in &callbacks {
- let callback = callback.get();
- if callback == 0 {
- continue;
- }
- let closure = unsafe { JsClosure::from_raw(callback as *const RawClosureHeader) };
- if !closure.is_null() {
- with_implicit_this(this_value, || unsafe {
- let _ = closure.call0();
- });
- fired += 1;
- }
+ let mut call = DeferredCallbacksCall {
+ callbacks: callbacks.as_ptr(),
+ len: callbacks.len(),
+ };
+ unsafe {
+ crate::js_async_hooks_provider_run_catching_with_this(
+ async_id,
+ this_value,
+ 1,
+ call_deferred_callbacks,
+ &mut call as *mut DeferredCallbacksCall as *mut std::ffi::c_void,
+ ) as i32
}
- fired
}
pub(super) fn server_is_active(s: &HttpServer) -> bool {
// #5011 — an `unref()`ed server no longer keeps the event loop alive
diff --git a/crates/perry-ext-net/src/dispatch.rs b/crates/perry-ext-net/src/dispatch.rs
index 8ecfd7b829..6789e01d8d 100644
--- a/crates/perry-ext-net/src/dispatch.rs
+++ b/crates/perry-ext-net/src/dispatch.rs
@@ -204,36 +204,21 @@ unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option
let result = match method {
"write" if !args.is_empty() => {
- // #5021 — call the DISTINCT, twin-free symbol directly so the
- // write reaches ext-net's registry regardless of link order.
- crate::js_ext_net_socket_write(handle, args[0].to_bits() as i64);
- if let Some(callback) = args.iter().copied().skip(1).find(|value| {
- extern "C" {
- fn js_value_is_closure(value_bits: i64) -> i32;
- }
- js_value_is_closure(value.to_bits() as i64) != 0
- }) {
- let raw = unbox_to_i64(callback) as *const RawClosureHeader;
- if !raw.is_null() {
- let _ = JsClosure::from_raw(raw).call0();
- }
- }
+ crate::js_ext_net_socket_write3(
+ handle,
+ args[0],
+ args.get(1).copied().unwrap_or_else(undefined),
+ args.get(2).copied().unwrap_or_else(undefined),
+ );
undefined()
}
"end" => {
- let chunk = args.first().copied().unwrap_or_else(undefined);
- crate::js_ext_net_socket_end(handle, chunk.to_bits() as i64);
- if let Some(callback) = args.iter().copied().find(|value| {
- extern "C" {
- fn js_value_is_closure(value_bits: i64) -> i32;
- }
- js_value_is_closure(value.to_bits() as i64) != 0
- }) {
- let raw = unbox_to_i64(callback) as *const RawClosureHeader;
- if !raw.is_null() {
- let _ = JsClosure::from_raw(raw).call0();
- }
- }
+ crate::js_ext_net_socket_end3(
+ handle,
+ args.first().copied().unwrap_or_else(undefined),
+ args.get(1).copied().unwrap_or_else(undefined),
+ args.get(2).copied().unwrap_or_else(undefined),
+ );
undefined()
}
"emit" if !args.is_empty() => {
diff --git a/crates/perry-ext-net/src/gc_roots.rs b/crates/perry-ext-net/src/gc_roots.rs
index df109d4016..35b7524cc6 100644
--- a/crates/perry-ext-net/src/gc_roots.rs
+++ b/crates/perry-ext-net/src/gc_roots.rs
@@ -60,6 +60,11 @@ pub(crate) fn scan_net_roots(visitor: &mut GcRootVisitor<'_>) {
}
}
}
+ if let Ok(mut completions) = crate::lifecycle::socket_completions().lock() {
+ for (_, callback) in completions.values_mut() {
+ visitor.visit_i64_slot(callback);
+ }
+ }
// #8259 — the pump's in-flight dispatch frames (snapshotted callbacks +
// parked payloads), which the table walks above cannot see.
dispatch_custody::scan(visitor);
diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs
index 4594c3459b..98ee742735 100644
--- a/crates/perry-ext-net/src/lib.rs
+++ b/crates/perry-ext-net/src/lib.rs
@@ -36,7 +36,7 @@
use bytes::{BufMut, Bytes};
use perry_ffi::{
alloc_buffer, alloc_string, gc_register_mutable_root_scanner_named, GcRootVisitor, JsClosure,
- JsPromise, JsValue, RawClosureHeader, StringHeader,
+ JsPromise, JsValue, RawClosureHeader, StringHeader, TransientRootScope,
};
use std::collections::HashMap;
use std::net::SocketAddr;
@@ -311,8 +311,8 @@ impl SocketState {
}
pub(crate) enum SocketCommand {
- Write(Vec),
- End,
+ Write(Vec, u64),
+ End(u64),
Destroy,
/// `socket.setNoDelay(enable)` — applies `TCP_NODELAY` to the live socket.
/// Carried as a command (rather than a flag on `SocketState`) because the
@@ -350,22 +350,16 @@ enum PendingNetEvent {
/// so the path from the receive buffer to the main-thread drain handler
/// (which only borrows it as `&[u8]`) stays alloc-free per read.
Data(i64, Bytes),
- /// Issue #1852 — peer half-closed (FIN received, `read()` returned 0).
- /// Node fires `'end'` on the readable side *before* `'close'`; lots of
- /// net tests block on `socket.on('end', …)` to learn the peer is done,
- /// so without this the connection lifecycle never completes and the
- /// test hangs.
+ /// Peer half-closed (FIN received); public readable-side `end` event.
End(i64),
- /// Completion of the writable-side shutdown requested by `socket.end()`.
- /// This is distinct from `End`, which is the peer's readable-side FIN and
+ /// Writable-side shutdown requested by `socket.end()`, distinct from FIN;
/// fires the public `end` event.
- ShutdownComplete(i64),
+ WriteComplete(i64, u64, Option),
+ ShutdownComplete(i64, u64, Option),
Close(i64),
Error(i64, String),
AbortError(i64),
- /// Issue #1123 followup — accept-loop on a `net.Server` produced
- /// a new client socket. Fires the server's `'connection'`
- /// listeners with the new socket handle.
+ /// Accept-loop produced a socket for the server's `connection` listeners.
/// `.0` = server id (for listener lookup)
/// `.1` = socket id (passed to listeners as the arg)
/// `.2` = loopback client callback has crossed a pump boundary
@@ -657,10 +651,9 @@ pub unsafe extern "C" fn js_ext_net_create_server(
#[no_mangle]
pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, arg3: f64) {
ensure_gc_scanner_registered();
- let callback_i64 = match js_net_callback_ptr(arg3) {
- 0 => js_net_callback_ptr(arg2),
- cb => cb,
- };
+ let roots = TransientRootScope::enter();
+ let arg2 = roots.root_nanbox(arg2);
+ let arg3 = roots.root_nanbox(arg3);
let path = ipc::string_value(port)
.or_else(|| is_nanboxed_pointer(port).then(|| get_object_string_field(port, "path"))?);
let (port_u16, host) = if path.is_some() {
@@ -676,11 +669,15 @@ pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64,
// #2013: a numeric `port` must be an integer in [0, 65536); Node throws
// RangeError [ERR_SOCKET_BAD_PORT] otherwise.
js_net_validate_listen_port(port);
- let host = string_from_header_i64(js_get_string_pointer_unified(arg2))
+ let host = string_from_header_i64(js_get_string_pointer_unified(arg2.get()))
.unwrap_or_else(|| "0.0.0.0".to_string());
(port as u16, host)
};
let server_async_id = init_provider(b"TCPSERVERWRAP");
+ let callback_i64 = match js_net_callback_ptr(arg3.get()) {
+ 0 => js_net_callback_ptr(arg2.get()),
+ cb => cb,
+ };
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
@@ -1240,16 +1237,35 @@ pub(crate) async fn run_socket_task(
rx.recv().await
};
match command {
- Some(SocketCommand::Write(bytes)) => {
+ Some(SocketCommand::Write(bytes, completion)) => {
if let Err(e) = t.write_all(&bytes).await {
- push_event(PendingNetEvent::Error(id, format!("{}", e)));
+ let msg = format!("{}", e);
+ if completion != 0 {
+ push_event(PendingNetEvent::WriteComplete(
+ id,
+ completion,
+ Some(msg.clone()),
+ ));
+ }
+ push_event(PendingNetEvent::Error(id, msg));
break;
}
+ if completion != 0 {
+ push_event(PendingNetEvent::WriteComplete(
+ id,
+ completion,
+ None,
+ ));
+ }
}
- Some(SocketCommand::End) => {
- let _ = t.shutdown().await;
+ Some(SocketCommand::End(completion)) => {
+ let error = t.shutdown().await.err().map(|e| e.to_string());
writable_ended = true;
- push_event(PendingNetEvent::ShutdownComplete(id));
+ push_event(PendingNetEvent::ShutdownComplete(
+ id,
+ completion,
+ error,
+ ));
}
Some(SocketCommand::SetNoDelay(enable)) => {
let _ = t.set_nodelay(enable);
@@ -1271,7 +1287,7 @@ pub(crate) async fn run_socket_task(
}
if !writable_ended {
let _ = t.shutdown().await;
- push_event(PendingNetEvent::ShutdownComplete(id));
+ push_event(PendingNetEvent::ShutdownComplete(id, 0, None));
}
push_event(PendingNetEvent::Close(id));
mark_closed(id);
@@ -1324,9 +1340,16 @@ pub(crate) async fn run_socket_task(
drop(window);
buffer_pool::checkin(buf);
match cmd {
- Some(SocketCommand::Write(bytes)) => {
+ Some(SocketCommand::Write(bytes, completion)) => {
if let Err(e) = t.write_all(&bytes).await {
let msg = format!("{}", e);
+ if completion != 0 {
+ push_event(PendingNetEvent::WriteComplete(
+ id,
+ completion,
+ Some(msg.clone()),
+ ));
+ }
if !raw_bridge::mark_terminal(id, Some(msg.clone())) {
push_event(PendingNetEvent::Error(id, msg));
push_event(PendingNetEvent::Close(id));
@@ -1334,11 +1357,14 @@ pub(crate) async fn run_socket_task(
mark_closed(id);
break;
}
+ if completion != 0 {
+ push_event(PendingNetEvent::WriteComplete(id, completion, None));
+ }
}
- Some(SocketCommand::End) => {
- let _ = t.shutdown().await;
+ Some(SocketCommand::End(completion)) => {
+ let error = t.shutdown().await.err().map(|e| e.to_string());
writable_ended = true;
- push_event(PendingNetEvent::ShutdownComplete(id));
+ push_event(PendingNetEvent::ShutdownComplete(id, completion, error));
}
Some(SocketCommand::SetNoDelay(enable)) => {
// Best-effort, matching Node: a failed setsockopt (e.g.
@@ -1618,7 +1644,7 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 {
.ok()
.and_then(|sockets| sockets.get(id).map(|socket| vec![socket.connect_async_id]))
.unwrap_or_default(),
- PendingNetEvent::ShutdownComplete(id) => statics::sockets()
+ PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets()
.lock()
.ok()
.and_then(|sockets| sockets.get(id).map(|socket| vec![socket.shutdown_async_id]))
@@ -1765,8 +1791,12 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 {
drop(frame);
lifecycle::drain_once_listeners(id, "end");
}
- PendingNetEvent::ShutdownComplete(_) => {}
+ PendingNetEvent::WriteComplete(_, completion, error)
+ | PendingNetEvent::ShutdownComplete(_, completion, error) => {
+ lifecycle::dispatch_socket_completion(completion, error);
+ }
PendingNetEvent::Close(id) => {
+ lifecycle::drop_socket_completions(id);
extern "C" {
fn js_tls_client_record_closed(handle: i64);
}
diff --git a/crates/perry-ext-net/src/lifecycle.rs b/crates/perry-ext-net/src/lifecycle.rs
index 9875eccaf0..2dee4476c3 100644
--- a/crates/perry-ext-net/src/lifecycle.rs
+++ b/crates/perry-ext-net/src/lifecycle.rs
@@ -20,11 +20,10 @@
//! `NativeModSig` rows live in
//! `perry-codegen/src/lower_call/native_table/net_events.rs`.
-use perry_ffi::{
- alloc_string, nanbox_string_bits, ArrayHeader, JsClosure, JsValue, RawClosureHeader,
- StringHeader,
-};
+use perry_ffi::{alloc_string, nanbox_string_bits, ArrayHeader, JsValue, StringHeader};
use std::collections::HashSet;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Mutex, OnceLock};
use crate::statics;
use crate::string_from_header_i64;
@@ -57,6 +56,40 @@ fn nanbox_undefined() -> f64 {
f64::from_bits(TAG_UNDEFINED_BITS)
}
+/// Main-thread custody for write/end callbacks awaiting socket-task I/O.
+pub(crate) fn socket_completions() -> &'static Mutex> {
+ static COMPLETIONS: OnceLock>> =
+ OnceLock::new();
+ COMPLETIONS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
+}
+
+pub(crate) unsafe fn dispatch_socket_completion(completion: u64, error: Option) {
+ let callback = (completion != 0)
+ .then(|| socket_completions().lock().unwrap().remove(&completion))
+ .flatten()
+ .map(|(_, callback)| callback)
+ .unwrap_or(0);
+ if callback == 0 {
+ return;
+ }
+ let mut frame = crate::dispatch_custody::DispatchFrame::park(vec![callback]);
+ if let Some(message) = error {
+ frame.set_payload(crate::build_error_object(&message).to_bits());
+ let _ = perry_ffi::JsClosure::from_raw(frame.cb(0) as *const perry_ffi::RawClosureHeader)
+ .call1(f64::from_bits(frame.payload_bits()));
+ } else {
+ let _ = perry_ffi::JsClosure::from_raw(frame.cb(0) as *const perry_ffi::RawClosureHeader)
+ .call0();
+ }
+}
+
+pub(crate) fn drop_socket_completions(socket_id: i64) {
+ socket_completions()
+ .lock()
+ .unwrap()
+ .retain(|_, (owner, _)| *owner != socket_id);
+}
+
/// NaN-box a freshly allocated runtime string as an `f64` JS value.
fn nanbox_string_value(s: &str) -> f64 {
let header = alloc_string(s).as_raw();
@@ -298,10 +331,22 @@ pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) {
Some(b) => b,
None => return,
};
+ enqueue_socket_write(handle, bytes, 0);
+}
+
+fn enqueue_socket_write(handle: i64, bytes: Vec, completion: u64) {
let mut sockets = statics::sockets().lock().unwrap();
if let Some(s) = sockets.get_mut(&handle) {
s.bytes_written = s.bytes_written.saturating_add(bytes.len() as u64);
- let _ = s.cmd_tx.send(crate::SocketCommand::Write(bytes));
+ if s.cmd_tx
+ .send(crate::SocketCommand::Write(bytes, completion))
+ .is_err()
+ && completion != 0
+ {
+ socket_completions().lock().unwrap().remove(&completion);
+ }
+ } else if completion != 0 {
+ socket_completions().lock().unwrap().remove(&completion);
}
}
@@ -318,20 +363,31 @@ pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) {
js_ext_net_socket_write(handle, chunk_bits);
}
-unsafe fn call_socket_completion(values: [f64; 3]) {
+unsafe fn socket_completion(values: [f64; 3]) -> i64 {
extern "C" {
fn js_value_is_closure(value_bits: i64) -> i32;
}
- if let Some(callback) = values
+ values
.into_iter()
.find(|value| js_value_is_closure(value.to_bits() as i64) != 0)
- {
- const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;
- let raw = (callback.to_bits() & POINTER_MASK) as *const RawClosureHeader;
- if !raw.is_null() {
- let _ = JsClosure::from_raw(raw).call0();
- }
+ .map(|callback| {
+ const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;
+ (callback.to_bits() & POINTER_MASK) as i64
+ })
+ .unwrap_or(0)
+}
+
+fn register_socket_completion(handle: i64, callback: i64) -> u64 {
+ static NEXT_COMPLETION: AtomicU64 = AtomicU64::new(1);
+ if callback == 0 {
+ return 0;
}
+ let token = NEXT_COMPLETION.fetch_add(1, Ordering::Relaxed);
+ socket_completions()
+ .lock()
+ .unwrap()
+ .insert(token, (handle, callback));
+ token
}
/// Full Node overload for `socket.write(chunk[, encoding][, callback])`.
@@ -342,8 +398,18 @@ pub unsafe extern "C" fn js_ext_net_socket_write3(
encoding_or_callback: f64,
callback: f64,
) {
- js_ext_net_socket_write(handle, chunk.to_bits() as i64);
- call_socket_completion([chunk, encoding_or_callback, callback]);
+ let roots = perry_ffi::TransientRootScope::enter();
+ let callback = roots.root_nanbox(callback);
+ let encoding_or_callback = roots.root_nanbox(encoding_or_callback);
+ let completion = socket_completion([chunk, encoding_or_callback.get(), callback.get()]);
+ let completion = register_socket_completion(handle, completion);
+ let Some(bytes) = crate::jsvalue_to_socket_bytes(chunk) else {
+ if completion != 0 {
+ socket_completions().lock().unwrap().remove(&completion);
+ }
+ return;
+ };
+ enqueue_socket_write(handle, bytes, completion);
}
/// `socket.end([data])` — optionally write a final chunk, then half-close the
@@ -361,6 +427,9 @@ pub unsafe extern "C" fn js_ext_net_socket_write3(
/// must reference live runtime allocations.
#[no_mangle]
pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) {
+ // Decode the GC-managed input before provider init can run user hooks and
+ // move it. Only owned bytes survive across that callback boundary.
+ let final_bytes = crate::jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64));
let trigger = statics::sockets().lock().ok().and_then(|sockets| {
sockets
.get(&handle)
@@ -374,13 +443,13 @@ pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) {
}
let mut sockets = statics::sockets().lock().unwrap();
if let Some(s) = sockets.get_mut(&handle) {
- if let Some(bytes) = crate::jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)) {
+ if let Some(bytes) = final_bytes {
if !bytes.is_empty() {
s.bytes_written = s.bytes_written.saturating_add(bytes.len() as u64);
- let _ = s.cmd_tx.send(crate::SocketCommand::Write(bytes));
+ let _ = s.cmd_tx.send(crate::SocketCommand::Write(bytes, 0));
}
}
- let _ = s.cmd_tx.send(crate::SocketCommand::End);
+ let _ = s.cmd_tx.send(crate::SocketCommand::End(0));
}
}
@@ -403,8 +472,45 @@ pub unsafe extern "C" fn js_ext_net_socket_end3(
encoding_or_callback: f64,
callback: f64,
) {
- js_ext_net_socket_end(handle, chunk_or_callback.to_bits() as i64);
- call_socket_completion([chunk_or_callback, encoding_or_callback, callback]);
+ let roots = perry_ffi::TransientRootScope::enter();
+ let chunk_or_callback = roots.root_nanbox(chunk_or_callback);
+ let encoding_or_callback = roots.root_nanbox(encoding_or_callback);
+ let callback = roots.root_nanbox(callback);
+ let completion = socket_completion([
+ chunk_or_callback.get(),
+ encoding_or_callback.get(),
+ callback.get(),
+ ]);
+ let completion = register_socket_completion(handle, completion);
+ let final_bytes = crate::jsvalue_to_socket_bytes(chunk_or_callback.get());
+ let trigger = statics::sockets().lock().ok().and_then(|sockets| {
+ sockets
+ .get(&handle)
+ .and_then(|socket| (socket.shutdown_async_id == 0).then_some(socket.tcp_async_id))
+ });
+ if let Some(trigger) = trigger {
+ let async_id = crate::init_provider_with_trigger(b"SHUTDOWNWRAP", trigger);
+ if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) {
+ socket.shutdown_async_id = async_id;
+ }
+ }
+ let mut sockets = statics::sockets().lock().unwrap();
+ if let Some(socket) = sockets.get_mut(&handle) {
+ if let Some(bytes) = final_bytes.filter(|bytes| !bytes.is_empty()) {
+ socket.bytes_written = socket.bytes_written.saturating_add(bytes.len() as u64);
+ let _ = socket.cmd_tx.send(crate::SocketCommand::Write(bytes, 0));
+ }
+ if socket
+ .cmd_tx
+ .send(crate::SocketCommand::End(completion))
+ .is_err()
+ && completion != 0
+ {
+ socket_completions().lock().unwrap().remove(&completion);
+ }
+ } else if completion != 0 {
+ socket_completions().lock().unwrap().remove(&completion);
+ }
}
/// `socket.destroy()` — hard close. Flags the handle destroyed (so
diff --git a/crates/perry-ext-net/src/provider_lifecycle.rs b/crates/perry-ext-net/src/provider_lifecycle.rs
index b6c848e8d3..7f276cd07c 100644
--- a/crates/perry-ext-net/src/provider_lifecycle.rs
+++ b/crates/perry-ext-net/src/provider_lifecycle.rs
@@ -51,7 +51,7 @@ pub(super) unsafe fn prepare_event_provider(ev: &PendingNetEvent) {
}
}
}
- PendingNetEvent::ShutdownComplete(id) => {
+ PendingNetEvent::ShutdownComplete(id, _, _) => {
let trigger = statics::sockets().lock().ok().and_then(|sockets| {
sockets.get(id).and_then(|socket| {
(socket.shutdown_async_id == 0).then_some(socket.tcp_async_id)
@@ -78,6 +78,7 @@ pub(super) fn event_provider_id(ev: &PendingNetEvent) -> u64 {
PendingNetEvent::SecureConnect(id)
| PendingNetEvent::Data(id, _)
| PendingNetEvent::End(id)
+ | PendingNetEvent::WriteComplete(id, _, _)
| PendingNetEvent::Error(id, _)
| PendingNetEvent::AbortError(id)
| PendingNetEvent::Close(id) => statics::sockets()
@@ -85,7 +86,7 @@ pub(super) fn event_provider_id(ev: &PendingNetEvent) -> u64 {
.ok()
.and_then(|sockets| sockets.get(id).map(|socket| socket.tcp_async_id))
.unwrap_or(0),
- PendingNetEvent::ShutdownComplete(id) => statics::sockets()
+ PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets()
.lock()
.ok()
.and_then(|sockets| sockets.get(id).map(|socket| socket.shutdown_async_id))
diff --git a/crates/perry-ext-net/src/raw_bridge.rs b/crates/perry-ext-net/src/raw_bridge.rs
index 6503fca510..9a51c88d76 100644
--- a/crates/perry-ext-net/src/raw_bridge.rs
+++ b/crates/perry-ext-net/src/raw_bridge.rs
@@ -105,7 +105,7 @@ extern "C" fn perry_net_raw_write(socket_id: i64, ptr: *const u8, len: usize) ->
};
if let Ok(g) = statics::sockets().lock() {
if let Some(s) = g.get(&socket_id) {
- return i32::from(s.cmd_tx.send(SocketCommand::Write(bytes)).is_ok());
+ return i32::from(s.cmd_tx.send(SocketCommand::Write(bytes, 0)).is_ok());
}
}
0
diff --git a/crates/perry-ext-qs/Cargo.toml b/crates/perry-ext-qs/Cargo.toml
new file mode 100644
index 0000000000..96211bb51e
--- /dev/null
+++ b/crates/perry-ext-qs/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "perry-ext-qs"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Native qs compatibility binding for nested query-string parsing and serialization"
+
+[lints]
+workspace = true
+
+[lib]
+crate-type = ["staticlib", "rlib"]
+
+[dependencies]
+perry-ffi.workspace = true
+serde_json.workspace = true
+
+[dev-dependencies]
+perry-ffi = { workspace = true, features = ["runtime-link"] }
+perry-runtime = { workspace = true, features = ["default", "stdlib"] }
diff --git a/crates/perry-ext-qs/src/codec.rs b/crates/perry-ext-qs/src/codec.rs
new file mode 100644
index 0000000000..d2458cfd98
--- /dev/null
+++ b/crates/perry-ext-qs/src/codec.rs
@@ -0,0 +1,128 @@
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Charset {
+ Utf8,
+ Latin1,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Format {
+ Rfc1738,
+ Rfc3986,
+}
+
+pub(crate) fn encode(input: &str, charset: Charset, format: Format) -> String {
+ let mut out = String::with_capacity(input.len());
+ match charset {
+ Charset::Utf8 => {
+ for &byte in input.as_bytes() {
+ if is_safe(byte, format) {
+ out.push(byte as char);
+ } else {
+ push_escape(&mut out, byte);
+ }
+ }
+ }
+ Charset::Latin1 => {
+ for unit in input.encode_utf16() {
+ if unit <= 0xFF {
+ let byte = unit as u8;
+ if is_safe(byte, format) {
+ out.push(byte as char);
+ } else {
+ push_escape(&mut out, byte);
+ }
+ } else {
+ out.push_str("%26%23");
+ out.push_str(&unit.to_string());
+ out.push_str("%3B");
+ }
+ }
+ }
+ }
+ if format == Format::Rfc1738 {
+ out = out.replace("%20", "+");
+ }
+ out
+}
+
+pub(crate) fn format_encoded(input: String, format: Format) -> String {
+ if format == Format::Rfc1738 {
+ input.replace("%20", "+")
+ } else {
+ input
+ }
+}
+
+pub(crate) fn decode(input: &str, charset: Charset) -> String {
+ let plus_replaced = input.replace('+', " ");
+ let mut bytes = Vec::with_capacity(plus_replaced.len());
+ let raw = plus_replaced.as_bytes();
+ let mut index = 0;
+ let mut invalid_escape = false;
+ while index < raw.len() {
+ if raw[index] == b'%' {
+ if index + 2 < raw.len() {
+ if let (Some(high), Some(low)) = (hex(raw[index + 1]), hex(raw[index + 2])) {
+ bytes.push((high << 4) | low);
+ index += 3;
+ continue;
+ }
+ }
+ invalid_escape = true;
+ }
+ bytes.push(raw[index]);
+ index += 1;
+ }
+
+ match charset {
+ Charset::Utf8 if invalid_escape => plus_replaced,
+ Charset::Utf8 => String::from_utf8(bytes).unwrap_or(plus_replaced),
+ Charset::Latin1 => bytes.into_iter().map(char::from).collect(),
+ }
+}
+
+fn is_safe(byte: u8, format: Format) -> bool {
+ byte.is_ascii_alphanumeric()
+ || matches!(byte, b'-' | b'.' | b'_' | b'~')
+ || (format == Format::Rfc1738 && matches!(byte, b'(' | b')'))
+}
+
+fn push_escape(out: &mut String, byte: u8) {
+ const HEX: &[u8; 16] = b"0123456789ABCDEF";
+ out.push('%');
+ out.push(HEX[(byte >> 4) as usize] as char);
+ out.push(HEX[(byte & 0xF) as usize] as char);
+}
+
+fn hex(byte: u8) -> Option {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rfc3986_encoding_matches_qs_defaults() {
+ assert_eq!(
+ encode("a b[c]/✓", Charset::Utf8, Format::Rfc3986),
+ "a%20b%5Bc%5D%2F%E2%9C%93"
+ );
+ }
+
+ #[test]
+ fn rfc1738_uses_plus_and_preserves_parentheses() {
+ assert_eq!(encode("a b(c)", Charset::Utf8, Format::Rfc1738), "a+b(c)");
+ }
+
+ #[test]
+ fn decoder_is_lenient_like_decode_uri_component_wrapper() {
+ assert_eq!(decode("a+b%5Bc%5D", Charset::Utf8), "a b[c]");
+ assert_eq!(decode("bad%ZZ", Charset::Utf8), "bad%ZZ");
+ }
+}
diff --git a/crates/perry-ext-qs/src/lib.rs b/crates/perry-ext-qs/src/lib.rs
new file mode 100644
index 0000000000..7e360fb168
--- /dev/null
+++ b/crates/perry-ext-qs/src/lib.rs
@@ -0,0 +1,46 @@
+//! Native compatibility binding for [`qs`](https://www.npmjs.com/package/qs).
+//!
+//! The binding exists primarily so packages such as Stripe can retain qs'
+//! nested request encoding without asking Perry's AOT compiler to compile the
+//! legacy `get-intrinsic` / ES-shims dependency chain. The implementation is
+//! intentionally dependency-light and crosses the runtime only through the
+//! stable `perry-ffi` surface plus existing C ABI symbols.
+
+mod codec;
+mod options;
+mod parse;
+mod runtime;
+mod stringify;
+
+#[cfg(test)]
+mod test_async_shims;
+
+use perry_ffi::{alloc_string, read_string, JsString, StringHeader, TransientRootScope};
+
+/// `qs.stringify(value, options?)`.
+#[no_mangle]
+pub extern "C" fn js_qs_stringify(value: f64, options: f64) -> *mut StringHeader {
+ alloc_string(&stringify::stringify(value, options)).as_raw()
+}
+
+/// `qs.parse(input, options?)`.
+///
+/// # Safety
+/// `input` must be null or a live Perry `StringHeader` pointer.
+#[no_mangle]
+pub unsafe extern "C" fn js_qs_parse(
+ input: *const StringHeader,
+ options: f64,
+) -> *mut StringHeader {
+ let input = if input.is_null() {
+ String::new()
+ } else {
+ let input = JsString::from_raw(input as *mut StringHeader);
+ read_string(input).unwrap_or_default().to_owned()
+ };
+ let scope = TransientRootScope::enter();
+ let mut options = options::ParseOptions::from_js(&scope, options);
+ let value = parse::parse(&input, &mut options);
+ let json = serde_json::to_string(&value).expect("qs parse tree is JSON serializable");
+ alloc_string(&json).as_raw()
+}
diff --git a/crates/perry-ext-qs/src/options.rs b/crates/perry-ext-qs/src/options.rs
new file mode 100644
index 0000000000..648d9b5e42
--- /dev/null
+++ b/crates/perry-ext-qs/src/options.rs
@@ -0,0 +1,360 @@
+use crate::codec::{Charset, Format};
+use crate::runtime;
+use perry_ffi::{
+ js_array_get, js_array_length, throw_with_code, ErrorKind, JsValue, TransientRootScope,
+ TransientRootedNanbox,
+};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum ArrayFormat {
+ Brackets,
+ Comma,
+ Indices,
+ Repeat,
+}
+
+pub(crate) struct StringifyOptions {
+ pub(crate) add_query_prefix: bool,
+ pub(crate) allow_dots: bool,
+ pub(crate) allow_empty_arrays: bool,
+ pub(crate) array_format: ArrayFormat,
+ pub(crate) charset: Charset,
+ pub(crate) charset_sentinel: bool,
+ pub(crate) comma_round_trip: bool,
+ pub(crate) delimiter: String,
+ pub(crate) encode: bool,
+ pub(crate) encode_dot_in_keys: bool,
+ pub(crate) encode_values_only: bool,
+ pub(crate) format: Format,
+ pub(crate) skip_nulls: bool,
+ pub(crate) strict_null_handling: bool,
+ pub(crate) encoder: Option,
+ pub(crate) filter: Option,
+ pub(crate) filter_keys: Option>,
+ pub(crate) serialize_date: Option,
+ pub(crate) sort: Option,
+}
+
+impl Default for StringifyOptions {
+ fn default() -> Self {
+ Self {
+ add_query_prefix: false,
+ allow_dots: false,
+ allow_empty_arrays: false,
+ array_format: ArrayFormat::Indices,
+ charset: Charset::Utf8,
+ charset_sentinel: false,
+ comma_round_trip: false,
+ delimiter: "&".to_owned(),
+ encode: true,
+ encode_dot_in_keys: false,
+ encode_values_only: false,
+ format: Format::Rfc3986,
+ skip_nulls: false,
+ strict_null_handling: false,
+ encoder: None,
+ filter: None,
+ filter_keys: None,
+ serialize_date: None,
+ sort: None,
+ }
+ }
+}
+
+impl StringifyOptions {
+ pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self {
+ let mut result = Self::default();
+ let value = runtime::from_f64(raw);
+ if !value.is_pointer() || runtime::is_closure(value) {
+ return result;
+ }
+ let options = scope.root_nanbox(raw);
+
+ validate_bool(scope, &options, "allowEmptyArrays");
+ validate_bool(scope, &options, "encodeDotInKeys");
+ validate_bool(scope, &options, "commaRoundTrip");
+
+ result.add_query_prefix = bool_option(scope, &options, "addQueryPrefix", false);
+ result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false);
+ result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false);
+ result.comma_round_trip = bool_option(scope, &options, "commaRoundTrip", false);
+ result.encode = bool_option(scope, &options, "encode", true);
+ result.encode_dot_in_keys = bool_option(scope, &options, "encodeDotInKeys", false);
+ result.encode_values_only = bool_option(scope, &options, "encodeValuesOnly", false);
+ result.skip_nulls = bool_option(scope, &options, "skipNulls", false);
+ result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false);
+
+ let allow_dots = field(scope, &options, "allowDots");
+ result.allow_dots = if allow_dots.is_undefined() {
+ result.encode_dot_in_keys
+ } else {
+ truthy(allow_dots)
+ };
+
+ let delimiter = field(scope, &options, "delimiter");
+ if !delimiter.is_undefined() {
+ result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter));
+ }
+
+ let charset = field(scope, &options, "charset");
+ if !charset.is_undefined() {
+ match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() {
+ Some("utf-8") => result.charset = Charset::Utf8,
+ Some("iso-8859-1") => result.charset = Charset::Latin1,
+ _ => {
+ throw_type("The charset option must be either utf-8, iso-8859-1, or undefined")
+ }
+ }
+ }
+
+ let format = field(scope, &options, "format");
+ if !format.is_undefined() {
+ match runtime::string_value(scope, runtime::as_f64(format)).as_deref() {
+ Some("RFC1738") => result.format = Format::Rfc1738,
+ Some("RFC3986") => result.format = Format::Rfc3986,
+ _ => throw_type("Unknown format option provided."),
+ }
+ }
+
+ let array_format = field(scope, &options, "arrayFormat");
+ result.array_format =
+ match runtime::string_value(scope, runtime::as_f64(array_format)).as_deref() {
+ Some("brackets") => ArrayFormat::Brackets,
+ Some("comma") => ArrayFormat::Comma,
+ Some("repeat") => ArrayFormat::Repeat,
+ Some("indices") => ArrayFormat::Indices,
+ _ => {
+ let indices = field(scope, &options, "indices");
+ if indices.is_undefined() || truthy(indices) {
+ ArrayFormat::Indices
+ } else {
+ ArrayFormat::Repeat
+ }
+ }
+ };
+
+ let encoder = field(scope, &options, "encoder");
+ if !encoder.is_undefined() && !encoder.is_null() {
+ if !runtime::is_closure(encoder) {
+ throw_type("Encoder has to be a function.");
+ }
+ result.encoder = Some(scope.root_nanbox(runtime::as_f64(encoder)));
+ }
+
+ let serialize_date = field(scope, &options, "serializeDate");
+ if runtime::is_closure(serialize_date) {
+ result.serialize_date = Some(scope.root_nanbox(runtime::as_f64(serialize_date)));
+ }
+
+ let sort = field(scope, &options, "sort");
+ if runtime::is_closure(sort) {
+ result.sort = Some(scope.root_nanbox(runtime::as_f64(sort)));
+ }
+
+ let filter = field(scope, &options, "filter");
+ if runtime::is_closure(filter) {
+ result.filter = Some(scope.root_nanbox(runtime::as_f64(filter)));
+ } else if runtime::is_array(runtime::as_f64(filter)) {
+ let filter = scope.root_nanbox(runtime::as_f64(filter));
+ let array = runtime::from_f64(filter.get()).as_pointer();
+ let length = unsafe { js_array_length(array) };
+ let mut keys = Vec::with_capacity(length as usize);
+ for index in 0..length {
+ let array = runtime::from_f64(filter.get()).as_pointer();
+ let value = unsafe { js_array_get(array, index) };
+ if !value.is_undefined() && !value.is_null() {
+ keys.push(runtime::owned_string(scope, runtime::as_f64(value)));
+ }
+ }
+ result.filter_keys = Some(keys);
+ }
+
+ result
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum DuplicateMode {
+ Combine,
+ First,
+ Last,
+}
+
+pub(crate) struct ParseOptions {
+ pub(crate) allow_dots: bool,
+ pub(crate) allow_empty_arrays: bool,
+ pub(crate) allow_prototypes: bool,
+ pub(crate) allow_sparse: bool,
+ pub(crate) array_limit: usize,
+ pub(crate) charset: Charset,
+ pub(crate) charset_sentinel: bool,
+ pub(crate) comma: bool,
+ pub(crate) decode_dot_in_keys: bool,
+ pub(crate) delimiter: String,
+ pub(crate) depth: usize,
+ pub(crate) duplicates: DuplicateMode,
+ pub(crate) ignore_query_prefix: bool,
+ pub(crate) interpret_numeric_entities: bool,
+ pub(crate) parameter_limit: usize,
+ pub(crate) parse_arrays: bool,
+ pub(crate) strict_depth: bool,
+ pub(crate) strict_null_handling: bool,
+ pub(crate) throw_on_limit_exceeded: bool,
+}
+
+impl Default for ParseOptions {
+ fn default() -> Self {
+ Self {
+ allow_dots: false,
+ allow_empty_arrays: false,
+ allow_prototypes: false,
+ allow_sparse: false,
+ array_limit: 20,
+ charset: Charset::Utf8,
+ charset_sentinel: false,
+ comma: false,
+ decode_dot_in_keys: false,
+ delimiter: "&".to_owned(),
+ depth: 5,
+ duplicates: DuplicateMode::Combine,
+ ignore_query_prefix: false,
+ interpret_numeric_entities: false,
+ parameter_limit: 1000,
+ parse_arrays: true,
+ strict_depth: false,
+ strict_null_handling: false,
+ throw_on_limit_exceeded: false,
+ }
+ }
+}
+
+impl ParseOptions {
+ pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self {
+ let mut result = Self::default();
+ let value = runtime::from_f64(raw);
+ if !value.is_pointer() || runtime::is_closure(value) {
+ return result;
+ }
+ let options = scope.root_nanbox(raw);
+
+ result.allow_dots = bool_option(scope, &options, "allowDots", false);
+ result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false);
+ result.allow_prototypes = bool_option(scope, &options, "allowPrototypes", false);
+ result.allow_sparse = bool_option(scope, &options, "allowSparse", false);
+ result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false);
+ result.comma = bool_option(scope, &options, "comma", false);
+ result.decode_dot_in_keys = bool_option(scope, &options, "decodeDotInKeys", false);
+ result.ignore_query_prefix = bool_option(scope, &options, "ignoreQueryPrefix", false);
+ result.interpret_numeric_entities =
+ bool_option(scope, &options, "interpretNumericEntities", false);
+ result.parse_arrays = bool_option(scope, &options, "parseArrays", true);
+ result.strict_depth = bool_option(scope, &options, "strictDepth", false);
+ result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false);
+ result.throw_on_limit_exceeded =
+ bool_option(scope, &options, "throwOnLimitExceeded", false);
+
+ result.array_limit = number_option(scope, &options, "arrayLimit", 20);
+ result.depth = number_option(scope, &options, "depth", 5);
+ result.parameter_limit = number_option(scope, &options, "parameterLimit", 1000);
+
+ let delimiter = field(scope, &options, "delimiter");
+ if !delimiter.is_undefined() {
+ if delimiter.is_pointer() && !delimiter.is_any_string() {
+ throw_type("Regular-expression delimiters are not supported by the native qs shim");
+ }
+ result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter));
+ }
+
+ let charset = field(scope, &options, "charset");
+ if !charset.is_undefined() {
+ match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() {
+ Some("utf-8") => result.charset = Charset::Utf8,
+ Some("iso-8859-1") => result.charset = Charset::Latin1,
+ _ => {
+ throw_type("The charset option must be either utf-8, iso-8859-1, or undefined")
+ }
+ }
+ }
+
+ let duplicates = field(scope, &options, "duplicates");
+ if !duplicates.is_undefined() {
+ result.duplicates =
+ match runtime::string_value(scope, runtime::as_f64(duplicates)).as_deref() {
+ Some("combine") => DuplicateMode::Combine,
+ Some("first") => DuplicateMode::First,
+ Some("last") => DuplicateMode::Last,
+ _ => throw_type("The duplicates option must be either combine, first, or last"),
+ };
+ }
+
+ let decoder = field(scope, &options, "decoder");
+ if !decoder.is_undefined() && !decoder.is_null() {
+ if !runtime::is_closure(decoder) {
+ throw_type("Decoder has to be a function.");
+ }
+ throw_type("Custom decoders are not supported by the native qs shim");
+ }
+
+ result
+ }
+}
+
+fn field(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) -> JsValue {
+ runtime::field_by_name(scope, options, name)
+}
+
+fn bool_option(
+ scope: &TransientRootScope,
+ options: &TransientRootedNanbox,
+ name: &str,
+ default: bool,
+) -> bool {
+ let value = field(scope, options, name);
+ if value.is_bool() {
+ value.to_bool()
+ } else {
+ default
+ }
+}
+
+fn validate_bool(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) {
+ let value = field(scope, options, name);
+ if !value.is_undefined() && !value.is_bool() {
+ throw_type(&format!(
+ "`{name}` option can only be `true` or `false`, when provided"
+ ));
+ }
+}
+
+fn number_option(
+ scope: &TransientRootScope,
+ options: &TransientRootedNanbox,
+ name: &str,
+ default: usize,
+) -> usize {
+ let value = field(scope, options, name);
+ if value.is_number() {
+ let value = value.to_number();
+ if value.is_finite() && value >= 0.0 {
+ return value.floor() as usize;
+ }
+ }
+ default
+}
+
+fn truthy(value: JsValue) -> bool {
+ if value.is_undefined() || value.is_null() {
+ false
+ } else if value.is_bool() {
+ value.to_bool()
+ } else if value.is_number() {
+ let number = value.to_number();
+ number != 0.0 && !number.is_nan()
+ } else {
+ true
+ }
+}
+
+fn throw_type(message: &str) -> ! {
+ throw_with_code(message, "", ErrorKind::TypeError)
+}
diff --git a/crates/perry-ext-qs/src/parse.rs b/crates/perry-ext-qs/src/parse.rs
new file mode 100644
index 0000000000..c22dcee8ee
--- /dev/null
+++ b/crates/perry-ext-qs/src/parse.rs
@@ -0,0 +1,449 @@
+use crate::codec::{self, Charset};
+use crate::options::{DuplicateMode, ParseOptions};
+use perry_ffi::{throw_with_code, ErrorKind};
+use serde_json::{Map, Value};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Segment {
+ Key(String),
+ Index(usize),
+ Append,
+}
+
+pub(crate) fn parse(input: &str, options: &mut ParseOptions) -> Value {
+ let input = if options.ignore_query_prefix {
+ input.strip_prefix('?').unwrap_or(input)
+ } else {
+ input
+ };
+ if input.is_empty() {
+ return Value::Object(Map::new());
+ }
+
+ let mut pairs: Vec<&str> = if options.delimiter.is_empty() {
+ vec![input]
+ } else {
+ input.split(&options.delimiter).collect()
+ };
+ if pairs.len() > options.parameter_limit {
+ if options.throw_on_limit_exceeded {
+ throw_with_code(
+ &format!(
+ "Parameter limit exceeded. Only {} parameter{} allowed.",
+ options.parameter_limit,
+ if options.parameter_limit == 1 {
+ " is"
+ } else {
+ "s are"
+ }
+ ),
+ "",
+ ErrorKind::RangeError,
+ );
+ }
+ pairs.truncate(options.parameter_limit);
+ }
+
+ if options.charset_sentinel {
+ if let Some((index, charset)) = pairs.iter().enumerate().find_map(|(index, pair)| {
+ if *pair == "utf8=%E2%9C%93" {
+ Some((index, Charset::Utf8))
+ } else if *pair == "utf8=%26%2310003%3B" {
+ Some((index, Charset::Latin1))
+ } else {
+ None
+ }
+ }) {
+ options.charset = charset;
+ pairs.remove(index);
+ }
+ }
+
+ let mut root = Value::Object(Map::new());
+ for pair in pairs {
+ let (raw_key, raw_value, had_equals) = match pair.find('=') {
+ Some(index) => (&pair[..index], &pair[index + 1..], true),
+ None => (pair, "", false),
+ };
+ let mut key = codec::decode(raw_key, options.charset);
+ if options.decode_dot_in_keys {
+ key = key.replace("%2E", ".").replace("%2e", ".");
+ }
+ let mut value = codec::decode(raw_value, options.charset);
+ if options.charset == Charset::Latin1 && options.interpret_numeric_entities {
+ value = decode_numeric_entities(&value);
+ }
+
+ let mut segments = parse_segments(&key, options);
+ if segments.is_empty() || forbidden_path(&segments, options.allow_prototypes) {
+ continue;
+ }
+
+ let empty_array = !had_equals
+ && options.allow_empty_arrays
+ && matches!(segments.last(), Some(Segment::Append));
+ let parsed_value = if empty_array {
+ segments.pop();
+ Value::Array(Vec::new())
+ } else if !had_equals && options.strict_null_handling {
+ Value::Null
+ } else if options.comma && value.contains(',') {
+ Value::Array(
+ value
+ .split(',')
+ .map(|part| Value::String(part.to_owned()))
+ .collect(),
+ )
+ } else {
+ Value::String(value)
+ };
+ insert(&mut root, &segments, parsed_value, options);
+ }
+ root
+}
+
+fn parse_segments(key: &str, options: &ParseOptions) -> Vec {
+ let use_dots = options.allow_dots || options.decode_dot_in_keys;
+ let mut raw_segments = Vec::new();
+ let mut index = key
+ .char_indices()
+ .find_map(|(index, ch)| (ch == '[' || (use_dots && ch == '.')).then_some(index))
+ .unwrap_or(key.len());
+ raw_segments.push(key[..index].to_owned());
+ let mut nested = 0usize;
+
+ while index < key.len() {
+ match key.as_bytes()[index] {
+ b'.' if use_dots => {
+ let start = index + 1;
+ let end = key[start..]
+ .char_indices()
+ .find_map(|(offset, ch)| {
+ (ch == '[' || (use_dots && ch == '.')).then_some(start + offset)
+ })
+ .unwrap_or(key.len());
+ raw_segments.push(key[start..end].to_owned());
+ index = end;
+ }
+ b'[' => {
+ let bracket_start = index;
+ let Some(close_offset) = key[index + 1..].find(']') else {
+ raw_segments.push(key[index..].to_owned());
+ break;
+ };
+ let close = index + 1 + close_offset;
+ nested += 1;
+ if nested > options.depth {
+ if options.strict_depth {
+ throw_with_code(
+ &format!(
+ "Input depth exceeded depth option of {} and strictDepth is true",
+ options.depth
+ ),
+ "",
+ ErrorKind::RangeError,
+ );
+ }
+ raw_segments.push(key[bracket_start..].to_owned());
+ index = key.len();
+ continue;
+ }
+ raw_segments.push(key[index + 1..close].to_owned());
+ index = close + 1;
+ }
+ _ => {
+ raw_segments.push(key[index..].to_owned());
+ break;
+ }
+ }
+ }
+
+ raw_segments
+ .into_iter()
+ .enumerate()
+ .map(|(position, segment)| {
+ if position > 0 && segment.is_empty() && options.parse_arrays {
+ Segment::Append
+ } else if position > 0 && options.parse_arrays {
+ match segment.parse::() {
+ Ok(index) if index <= options.array_limit => Segment::Index(index),
+ _ => Segment::Key(segment),
+ }
+ } else {
+ Segment::Key(segment)
+ }
+ })
+ .collect()
+}
+
+fn forbidden_path(segments: &[Segment], allow_prototypes: bool) -> bool {
+ const OBJECT_PROTOTYPE_KEYS: &[&str] = &[
+ "__defineGetter__",
+ "__defineSetter__",
+ "__lookupGetter__",
+ "__lookupSetter__",
+ "constructor",
+ "hasOwnProperty",
+ "isPrototypeOf",
+ "propertyIsEnumerable",
+ "toLocaleString",
+ "toString",
+ "valueOf",
+ ];
+ segments.iter().any(|segment| match segment {
+ Segment::Key(key) if key == "__proto__" => true,
+ Segment::Key(key) if !allow_prototypes => OBJECT_PROTOTYPE_KEYS.contains(&key.as_str()),
+ _ => false,
+ })
+}
+
+fn insert(node: &mut Value, segments: &[Segment], value: Value, options: &ParseOptions) {
+ let Some((segment, rest)) = segments.split_first() else {
+ merge_leaf(node, value, options.duplicates);
+ return;
+ };
+
+ match segment {
+ Segment::Key(key) => {
+ if !node.is_object() {
+ *node = Value::Object(Map::new());
+ }
+ let object = node.as_object_mut().expect("object initialized");
+ if rest.is_empty() {
+ match object.get_mut(key) {
+ Some(existing) => merge_leaf(existing, value, options.duplicates),
+ None => {
+ object.insert(key.clone(), value);
+ }
+ }
+ return;
+ }
+ let child = object
+ .entry(key.clone())
+ .or_insert_with(|| empty_container(&rest[0], options));
+ insert(child, rest, value, options);
+ }
+ Segment::Index(requested) => {
+ if !node.is_array() {
+ *node = Value::Array(Vec::new());
+ }
+ let array = node.as_array_mut().expect("array initialized");
+ let position = if options.allow_sparse {
+ while array.len() <= *requested {
+ array.push(Value::Null);
+ }
+ *requested
+ } else if *requested < array.len() {
+ *requested
+ } else {
+ if rest.is_empty() {
+ array.push(value);
+ return;
+ }
+ array.push(empty_container(&rest[0], options));
+ let position = array.len() - 1;
+ insert(&mut array[position], rest, value, options);
+ return;
+ };
+ if rest.is_empty() {
+ if options.allow_sparse && array[position].is_null() {
+ array[position] = value;
+ } else {
+ merge_leaf(&mut array[position], value, options.duplicates);
+ }
+ } else {
+ if array[position].is_null() {
+ array[position] = empty_container(&rest[0], options);
+ }
+ insert(&mut array[position], rest, value, options);
+ }
+ }
+ Segment::Append => {
+ if !node.is_array() {
+ *node = Value::Array(Vec::new());
+ }
+ let array = node.as_array_mut().expect("array initialized");
+ if rest.is_empty() {
+ array.push(value);
+ } else {
+ let mut child = empty_container(&rest[0], options);
+ insert(&mut child, rest, value, options);
+ array.push(child);
+ }
+ }
+ }
+}
+
+fn empty_container(next: &Segment, options: &ParseOptions) -> Value {
+ if options.parse_arrays && matches!(next, Segment::Index(_) | Segment::Append) {
+ Value::Array(Vec::new())
+ } else {
+ Value::Object(Map::new())
+ }
+}
+
+fn merge_leaf(existing: &mut Value, value: Value, mode: DuplicateMode) {
+ match mode {
+ DuplicateMode::First => {}
+ DuplicateMode::Last => *existing = value,
+ DuplicateMode::Combine => match existing {
+ Value::Array(values) => values.push(value),
+ _ => {
+ let previous = std::mem::replace(existing, Value::Null);
+ *existing = Value::Array(vec![previous, value]);
+ }
+ },
+ }
+}
+
+fn decode_numeric_entities(input: &str) -> String {
+ let mut output = String::with_capacity(input.len());
+ let mut rest = input;
+ while let Some(start) = rest.find("") {
+ output.push_str(&rest[..start]);
+ let entity = &rest[start + 2..];
+ let Some(end) = entity.find(';') else {
+ output.push_str(&rest[start..]);
+ return output;
+ };
+ let digits = &entity[..end];
+ if let Ok(codepoint) = digits.parse::() {
+ if let Some(ch) = char::from_u32(codepoint) {
+ output.push(ch);
+ } else {
+ output.push_str(&rest[start..start + end + 3]);
+ }
+ } else {
+ output.push_str(&rest[start..start + end + 3]);
+ }
+ rest = &entity[end + 1..];
+ }
+ output.push_str(rest);
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn parsed(input: &str) -> Value {
+ parse(input, &mut ParseOptions::default())
+ }
+
+ #[test]
+ fn parses_nested_objects_arrays_and_duplicates() {
+ assert_eq!(
+ parsed("customer[name]=Ada&items[0][id]=price_1&items[1][id]=price_2&tag=a&tag=b"),
+ serde_json::json!({
+ "customer": { "name": "Ada" },
+ "items": [{ "id": "price_1" }, { "id": "price_2" }],
+ "tag": ["a", "b"]
+ })
+ );
+ }
+
+ #[test]
+ fn blocks_prototype_pollution_segments() {
+ assert_eq!(
+ parsed("safe=yes&__proto__[polluted]=yes&constructor[prototype][bad]=yes"),
+ serde_json::json!({ "safe": "yes" })
+ );
+ }
+
+ #[test]
+ fn array_limit_falls_back_to_object_key() {
+ assert_eq!(parsed("a[21]=x"), serde_json::json!({ "a": { "21": "x" } }));
+ }
+
+ #[test]
+ fn supports_dot_and_sparse_modes() {
+ let mut options = ParseOptions {
+ allow_dots: true,
+ allow_sparse: true,
+ ..ParseOptions::default()
+ };
+ assert_eq!(
+ parse("a.b[2]=x", &mut options),
+ serde_json::json!({ "a": { "b": [null, null, "x"] } })
+ );
+ }
+
+ #[test]
+ fn allow_empty_arrays_matches_qs_without_strict_null_mode() {
+ let mut options = ParseOptions {
+ allow_empty_arrays: true,
+ ..ParseOptions::default()
+ };
+ assert_eq!(
+ parse("foo[]", &mut options),
+ serde_json::json!({ "foo": [] })
+ );
+ }
+
+ #[test]
+ fn duplicates_modes_match_qs() {
+ let mut first = ParseOptions {
+ duplicates: DuplicateMode::First,
+ ..ParseOptions::default()
+ };
+ let mut last = ParseOptions {
+ duplicates: DuplicateMode::Last,
+ ..ParseOptions::default()
+ };
+ assert_eq!(
+ parse("a=b&a=c", &mut first),
+ serde_json::json!({ "a": "b" })
+ );
+ assert_eq!(parse("a=b&a=c", &mut last), serde_json::json!({ "a": "c" }));
+ }
+
+ #[test]
+ fn query_prefix_comma_and_strict_null_options_match_qs() {
+ let mut comma = ParseOptions {
+ ignore_query_prefix: true,
+ comma: true,
+ ..ParseOptions::default()
+ };
+ assert_eq!(
+ parse("?a=b,c", &mut comma),
+ serde_json::json!({ "a": ["b", "c"] })
+ );
+
+ let mut strict = ParseOptions {
+ strict_null_handling: true,
+ ..ParseOptions::default()
+ };
+ assert_eq!(
+ parse("a&b=", &mut strict),
+ serde_json::json!({ "a": null, "b": "" })
+ );
+ }
+
+ #[test]
+ fn charset_sentinel_depth_and_encoded_dots_match_qs() {
+ let mut charset = ParseOptions {
+ charset_sentinel: true,
+ ..ParseOptions::default()
+ };
+ assert_eq!(
+ parse("utf8=%26%2310003%3B&a=%F8", &mut charset),
+ serde_json::json!({ "a": "ø" })
+ );
+
+ assert_eq!(
+ parsed("a[b][c][d][e][f][g]=h"),
+ serde_json::json!({
+ "a": { "b": { "c": { "d": { "e": { "f": { "[g]": "h" } } } } } }
+ })
+ );
+
+ let mut dots = ParseOptions {
+ decode_dot_in_keys: true,
+ ..ParseOptions::default()
+ };
+ assert_eq!(
+ parse("a%2Eb=c", &mut dots),
+ serde_json::json!({ "a": { "b": "c" } })
+ );
+ }
+}
diff --git a/crates/perry-ext-qs/src/runtime.rs b/crates/perry-ext-qs/src/runtime.rs
new file mode 100644
index 0000000000..8e46266fc7
--- /dev/null
+++ b/crates/perry-ext-qs/src/runtime.rs
@@ -0,0 +1,134 @@
+use perry_ffi::{
+ alloc_string, read_string, ArrayHeader, ClosureHeader, JsClosure, JsString, JsValue,
+ ObjectHeader, StringHeader, TransientRootScope, TransientRootedNanbox,
+};
+
+extern "C" {
+ fn js_array_is_array(value: f64) -> f64;
+ fn js_date_to_iso_string_or_throw(value: f64) -> *mut StringHeader;
+ fn js_get_string_pointer_unified(value: f64) -> i64;
+ fn js_jsvalue_to_string(value: f64) -> *mut StringHeader;
+ fn js_object_get_field_by_name(
+ object: *const ObjectHeader,
+ key: *const StringHeader,
+ ) -> JsValue;
+ fn js_object_keys_value(value: f64) -> *mut ArrayHeader;
+ fn js_util_types_is_date(value: f64) -> f64;
+ fn js_value_is_closure(value_bits: i64) -> i32;
+}
+
+#[inline]
+pub(crate) fn as_f64(value: JsValue) -> f64 {
+ f64::from_bits(value.bits())
+}
+
+#[inline]
+pub(crate) fn from_f64(value: f64) -> JsValue {
+ JsValue::from_bits(value.to_bits())
+}
+
+pub(crate) fn is_array(value: f64) -> bool {
+ from_f64(unsafe { js_array_is_array(value) }).to_bool()
+}
+
+pub(crate) fn is_date(value: f64) -> bool {
+ from_f64(unsafe { js_util_types_is_date(value) }).to_bool()
+}
+
+pub(crate) fn is_closure(value: JsValue) -> bool {
+ unsafe { js_value_is_closure(value.bits() as i64) != 0 }
+}
+
+pub(crate) fn owned_string(scope: &TransientRootScope, value: f64) -> String {
+ let rooted = scope.root_nanbox(value);
+ let ptr = unsafe { js_jsvalue_to_string(rooted.get()) };
+ read_owned_header(ptr)
+}
+
+pub(crate) fn string_value(scope: &TransientRootScope, value: f64) -> Option {
+ let rooted = scope.root_nanbox(value);
+ let js = from_f64(rooted.get());
+ if !js.is_any_string() {
+ return None;
+ }
+ let ptr = unsafe { js_get_string_pointer_unified(rooted.get()) } as *mut StringHeader;
+ Some(read_owned_header(ptr))
+}
+
+pub(crate) fn date_iso(scope: &TransientRootScope, value: f64) -> String {
+ let rooted = scope.root_nanbox(value);
+ let ptr = unsafe { js_date_to_iso_string_or_throw(rooted.get()) };
+ read_owned_header(ptr)
+}
+
+pub(crate) fn object_keys(
+ scope: &TransientRootScope,
+ value: &TransientRootedNanbox,
+) -> TransientRootedNanbox {
+ let keys = unsafe { js_object_keys_value(value.get()) };
+ let boxed = JsValue::from_object_ptr(keys);
+ scope.root_nanbox(as_f64(boxed))
+}
+
+pub(crate) fn field_by_name(
+ scope: &TransientRootScope,
+ object: &TransientRootedNanbox,
+ name: &str,
+) -> JsValue {
+ let key = JsValue::from_string_ptr(alloc_string(name).as_raw());
+ let key = scope.root_nanbox(as_f64(key));
+ field_by_key(object, &key)
+}
+
+pub(crate) fn field_by_key(object: &TransientRootedNanbox, key: &TransientRootedNanbox) -> JsValue {
+ let key_value = from_f64(key.get());
+ let key = if key_value.is_string() {
+ key_value.as_string_ptr()
+ } else {
+ (unsafe { js_get_string_pointer_unified(key.get()) }) as *mut StringHeader
+ };
+ // Materializing an SSO key may allocate and move the object. Reload the
+ // rooted object only after the key is a stable heap string.
+ let object = from_f64(object.get()).as_pointer::();
+ if object.is_null() || key.is_null() {
+ JsValue::UNDEFINED
+ } else {
+ unsafe { js_object_get_field_by_name(object, key) }
+ }
+}
+
+pub(crate) fn call1(scope: &TransientRootScope, callback: &TransientRootedNanbox, arg: f64) -> f64 {
+ let arg = scope.root_nanbox(arg);
+ let callback_value = from_f64(callback.get());
+ let closure = unsafe {
+ JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader)
+ };
+ unsafe { closure.call1(arg.get()) }
+}
+
+pub(crate) fn call2(
+ scope: &TransientRootScope,
+ callback: &TransientRootedNanbox,
+ arg0: f64,
+ arg1: f64,
+) -> f64 {
+ let arg0 = scope.root_nanbox(arg0);
+ let arg1 = scope.root_nanbox(arg1);
+ let callback_value = from_f64(callback.get());
+ let closure = unsafe {
+ JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader)
+ };
+ unsafe { closure.call2(arg0.get(), arg1.get()) }
+}
+
+pub(crate) fn alloc_string_value(value: &str) -> f64 {
+ as_f64(JsValue::from_string_ptr(alloc_string(value).as_raw()))
+}
+
+fn read_owned_header(ptr: *mut StringHeader) -> String {
+ if ptr.is_null() {
+ return String::new();
+ }
+ let string = unsafe { JsString::from_raw(ptr) };
+ read_string(string).unwrap_or_default().to_owned()
+}
diff --git a/crates/perry-ext-qs/src/stringify.rs b/crates/perry-ext-qs/src/stringify.rs
new file mode 100644
index 0000000000..4e62fcf65d
--- /dev/null
+++ b/crates/perry-ext-qs/src/stringify.rs
@@ -0,0 +1,330 @@
+use crate::codec;
+use crate::options::{ArrayFormat, StringifyOptions};
+use crate::runtime;
+use perry_ffi::{
+ js_array_get, js_array_length, throw_with_code, value_byte_slice, ErrorKind,
+ TransientRootScope, TransientRootedNanbox,
+};
+use std::cmp::Ordering;
+
+pub(crate) fn stringify(value: f64, options: f64) -> String {
+ let scope = TransientRootScope::enter();
+ let options = StringifyOptions::from_js(&scope, options);
+ let mut root = scope.root_nanbox(value);
+
+ if let Some(filter) = &options.filter {
+ root = apply_filter(&scope, filter, "", root.get());
+ }
+
+ let root_value = runtime::from_f64(root.get());
+ if !root_value.is_pointer() || root_value.is_null() || runtime::is_closure(root_value) {
+ return String::new();
+ }
+
+ let mut keys = options
+ .filter_keys
+ .clone()
+ .unwrap_or_else(|| own_keys(&scope, &root));
+ sort_keys(&scope, &options, &mut keys);
+
+ let mut values = Vec::new();
+ let mut ancestors = vec![root];
+ for key in keys {
+ let value = runtime::field_by_name(&scope, &root, &key);
+ if options.skip_nulls && value.is_null() {
+ continue;
+ }
+ values.extend(stringify_value(
+ &scope,
+ &options,
+ runtime::as_f64(value),
+ key,
+ &mut ancestors,
+ ));
+ }
+
+ let joined = values.join(&options.delimiter);
+ if joined.is_empty() {
+ return joined;
+ }
+
+ let mut prefix = String::new();
+ if options.add_query_prefix {
+ prefix.push('?');
+ }
+ if options.charset_sentinel {
+ match options.charset {
+ codec::Charset::Utf8 => prefix.push_str("utf8=%E2%9C%93"),
+ codec::Charset::Latin1 => prefix.push_str("utf8=%26%2310003%3B"),
+ }
+ prefix.push_str(&options.delimiter);
+ }
+ prefix + joined.as_str()
+}
+
+fn stringify_value(
+ scope: &TransientRootScope,
+ options: &StringifyOptions,
+ raw: f64,
+ mut prefix: String,
+ ancestors: &mut Vec,
+) -> Vec {
+ let mut value = scope.root_nanbox(raw);
+
+ if let Some(filter) = &options.filter {
+ value = apply_filter(scope, filter, &prefix, value.get());
+ } else if runtime::is_date(value.get()) {
+ value = if let Some(callback) = &options.serialize_date {
+ scope.root_nanbox(runtime::call1(scope, callback, value.get()))
+ } else {
+ let iso = runtime::date_iso(scope, value.get());
+ scope.root_nanbox(runtime::alloc_string_value(&iso))
+ };
+ }
+
+ let js = runtime::from_f64(value.get());
+ if js.is_null() {
+ if options.strict_null_handling {
+ return vec![encode_key(scope, options, &prefix)];
+ }
+ value = scope.root_nanbox(runtime::alloc_string_value(""));
+ }
+
+ let js = runtime::from_f64(value.get());
+ if js.is_undefined() || runtime::is_closure(js) {
+ return Vec::new();
+ }
+
+ if let Some(bytes) = value_byte_slice(js) {
+ let text = String::from_utf8_lossy(bytes).into_owned();
+ return vec![format!(
+ "{}={}",
+ encode_key(scope, options, &prefix),
+ encode_text(scope, options, &text, false)
+ )];
+ }
+
+ if !js.is_pointer() {
+ return vec![format!(
+ "{}={}",
+ encode_key(scope, options, &prefix),
+ encode_value(scope, options, value.get())
+ )];
+ }
+
+ let is_array = runtime::is_array(value.get());
+ if is_array && options.array_format == ArrayFormat::Comma {
+ return stringify_comma_array(scope, options, &value, prefix);
+ }
+
+ if ancestors
+ .iter()
+ .any(|ancestor| same_heap_value(ancestor.get(), value.get()))
+ {
+ throw_with_code("Cyclic object value", "", ErrorKind::RangeError);
+ }
+ ancestors.push(value);
+
+ let mut keys = options
+ .filter_keys
+ .clone()
+ .unwrap_or_else(|| own_keys(scope, &value));
+ sort_keys(scope, options, &mut keys);
+
+ if options.encode_dot_in_keys {
+ prefix = prefix.replace('.', "%2E");
+ }
+ let adjusted_prefix = if is_array
+ && options.array_format == ArrayFormat::Comma
+ && options.comma_round_trip
+ && keys.len() == 1
+ {
+ format!("{prefix}[]")
+ } else {
+ prefix
+ };
+
+ if options.allow_empty_arrays && is_array && keys.is_empty() {
+ ancestors.pop();
+ return vec![format!("{adjusted_prefix}[]")];
+ }
+
+ let mut values = Vec::new();
+ for key in keys {
+ let child = runtime::field_by_name(scope, &value, &key);
+ if options.skip_nulls && child.is_null() {
+ continue;
+ }
+ let key = if options.allow_dots && options.encode_dot_in_keys {
+ key.replace('.', "%2E")
+ } else {
+ key
+ };
+ let child_prefix = if is_array {
+ match options.array_format {
+ ArrayFormat::Brackets => format!("{adjusted_prefix}[]"),
+ ArrayFormat::Indices => format!("{adjusted_prefix}[{key}]"),
+ ArrayFormat::Repeat => adjusted_prefix.clone(),
+ ArrayFormat::Comma => unreachable!(),
+ }
+ } else if options.allow_dots {
+ format!("{adjusted_prefix}.{key}")
+ } else {
+ format!("{adjusted_prefix}[{key}]")
+ };
+ values.extend(stringify_value(
+ scope,
+ options,
+ runtime::as_f64(child),
+ child_prefix,
+ ancestors,
+ ));
+ }
+ ancestors.pop();
+ values
+}
+
+fn stringify_comma_array(
+ scope: &TransientRootScope,
+ options: &StringifyOptions,
+ value: &TransientRootedNanbox,
+ mut prefix: String,
+) -> Vec {
+ let array = runtime::from_f64(value.get()).as_pointer();
+ let length = unsafe { js_array_length(array) };
+ if length == 0 {
+ return if options.allow_empty_arrays {
+ vec![format!("{prefix}[]")]
+ } else {
+ Vec::new()
+ };
+ }
+
+ if options.comma_round_trip && length == 1 {
+ prefix.push_str("[]");
+ }
+ let mut parts = Vec::with_capacity(length as usize);
+ for index in 0..length {
+ let array = runtime::from_f64(value.get()).as_pointer();
+ let mut item = unsafe { js_array_get(array, index) };
+ if runtime::is_date(runtime::as_f64(item)) {
+ item = if let Some(callback) = &options.serialize_date {
+ runtime::from_f64(runtime::call1(scope, callback, runtime::as_f64(item)))
+ } else {
+ let iso = runtime::date_iso(scope, runtime::as_f64(item));
+ runtime::from_f64(runtime::alloc_string_value(&iso))
+ };
+ }
+ if item.is_null() || item.is_undefined() {
+ parts.push(String::new());
+ } else {
+ let text = runtime::owned_string(scope, runtime::as_f64(item));
+ parts.push(if options.encode_values_only && options.encode {
+ encode_text(scope, options, &text, false)
+ } else {
+ text
+ });
+ }
+ }
+ let joined = parts.join(",");
+ if joined.is_empty() && options.strict_null_handling {
+ vec![encode_key(scope, options, &prefix)]
+ } else {
+ let encoded_value = if options.encode_values_only && options.encode {
+ codec::format_encoded(joined, options.format)
+ } else {
+ encode_text(scope, options, &joined, false)
+ };
+ vec![format!(
+ "{}={}",
+ encode_key(scope, options, &prefix),
+ encoded_value
+ )]
+ }
+}
+
+fn own_keys(scope: &TransientRootScope, value: &TransientRootedNanbox) -> Vec {
+ let keys = runtime::object_keys(scope, value);
+ let array = runtime::from_f64(keys.get()).as_pointer();
+ let length = unsafe { js_array_length(array) };
+ let mut result = Vec::with_capacity(length as usize);
+ for index in 0..length {
+ let array = runtime::from_f64(keys.get()).as_pointer();
+ let key = unsafe { js_array_get(array, index) };
+ result.push(runtime::owned_string(scope, runtime::as_f64(key)));
+ }
+ result
+}
+
+fn sort_keys(scope: &TransientRootScope, options: &StringifyOptions, keys: &mut [String]) {
+ let Some(callback) = &options.sort else {
+ return;
+ };
+ keys.sort_by(|left, right| {
+ let left = scope.root_nanbox(runtime::alloc_string_value(left));
+ let right = scope.root_nanbox(runtime::alloc_string_value(right));
+ let result = runtime::from_f64(runtime::call2(scope, callback, left.get(), right.get()));
+ let number = result.to_number();
+ if number < 0.0 {
+ Ordering::Less
+ } else if number > 0.0 {
+ Ordering::Greater
+ } else {
+ Ordering::Equal
+ }
+ });
+}
+
+fn apply_filter(
+ scope: &TransientRootScope,
+ callback: &TransientRootedNanbox,
+ prefix: &str,
+ value: f64,
+) -> TransientRootedNanbox {
+ let value = scope.root_nanbox(value);
+ let prefix = scope.root_nanbox(runtime::alloc_string_value(prefix));
+ scope.root_nanbox(runtime::call2(scope, callback, prefix.get(), value.get()))
+}
+
+fn encode_key(scope: &TransientRootScope, options: &StringifyOptions, key: &str) -> String {
+ if options.encode_values_only {
+ codec::format_encoded(key.to_owned(), options.format)
+ } else {
+ encode_text(scope, options, key, true)
+ }
+}
+
+fn encode_value(scope: &TransientRootScope, options: &StringifyOptions, value: f64) -> String {
+ if !options.encode {
+ return codec::format_encoded(runtime::owned_string(scope, value), options.format);
+ }
+ if let Some(callback) = &options.encoder {
+ let encoded = runtime::call1(scope, callback, value);
+ return codec::format_encoded(runtime::owned_string(scope, encoded), options.format);
+ }
+ let text = runtime::owned_string(scope, value);
+ codec::encode(&text, options.charset, options.format)
+}
+
+fn encode_text(
+ scope: &TransientRootScope,
+ options: &StringifyOptions,
+ text: &str,
+ _is_key: bool,
+) -> String {
+ if !options.encode {
+ return codec::format_encoded(text.to_owned(), options.format);
+ }
+ if let Some(callback) = &options.encoder {
+ let value = runtime::alloc_string_value(text);
+ let encoded = runtime::call1(scope, callback, value);
+ return codec::format_encoded(runtime::owned_string(scope, encoded), options.format);
+ }
+ codec::encode(text, options.charset, options.format)
+}
+
+fn same_heap_value(left: f64, right: f64) -> bool {
+ let left = runtime::from_f64(left);
+ let right = runtime::from_f64(right);
+ left.is_pointer() && right.is_pointer() && left.as_pointer::() == right.as_pointer::()
+}
diff --git a/crates/perry-ext-qs/src/test_async_shims.rs b/crates/perry-ext-qs/src/test_async_shims.rs
new file mode 100644
index 0000000000..30431722a4
--- /dev/null
+++ b/crates/perry-ext-qs/src/test_async_shims.rs
@@ -0,0 +1,113 @@
+//! Test-only host shims for the standalone extension test binary.
+
+use perry_ffi::{NativeAsyncCompletion, Promise};
+use std::ffi::c_void;
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_promise_new() -> *mut Promise {
+ perry_runtime::promise::js_promise_new() as *mut Promise
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_promise_resolve_bits(promise: *mut Promise, bits: u64) {
+ perry_runtime::promise::js_promise_resolve(
+ promise as *mut perry_runtime::Promise,
+ f64::from_bits(bits),
+ );
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64) {
+ perry_runtime::promise::js_promise_reject(
+ promise as *mut perry_runtime::Promise,
+ f64::from_bits(bits),
+ );
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_promise_resolve_deferred(
+ promise: *mut Promise,
+ context: *mut c_void,
+ invoke: extern "C" fn(*mut c_void) -> u64,
+) {
+ perry_ffi_promise_resolve_bits(promise, invoke(context));
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_promise_reject_deferred(
+ promise: *mut Promise,
+ context: *mut c_void,
+ invoke: extern "C" fn(*mut c_void) -> u64,
+) {
+ perry_ffi_promise_reject_bits(promise, invoke(context));
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_spawn_blocking(
+ context: *mut c_void,
+ invoke: extern "C" fn(*mut c_void),
+) {
+ invoke(context);
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_spawn_blocking_with_reactor(
+ context: *mut c_void,
+ invoke: extern "C" fn(*mut c_void),
+) {
+ invoke(context);
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_new(_flags: u32) -> *mut NativeAsyncCompletion {
+ std::ptr::null_mut()
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_promise(
+ _token: *mut NativeAsyncCompletion,
+) -> *mut Promise {
+ std::ptr::null_mut()
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_resolve_bits(
+ _token: *mut NativeAsyncCompletion,
+ _bits: u64,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_reject_bits(
+ _token: *mut NativeAsyncCompletion,
+ _bits: u64,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_reject_string(
+ _token: *mut NativeAsyncCompletion,
+ _data: *const u8,
+ _len: usize,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_cancel(_token: *mut NativeAsyncCompletion) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_attach_handle(
+ _token: *mut NativeAsyncCompletion,
+ _handle_bits: u64,
+ _cleanup_flags: u32,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {}
diff --git a/crates/perry-ext-sharp/src/lib.rs b/crates/perry-ext-sharp/src/lib.rs
index 6192ce1736..480b8d8388 100644
--- a/crates/perry-ext-sharp/src/lib.rs
+++ b/crates/perry-ext-sharp/src/lib.rs
@@ -9,7 +9,7 @@ use perry_ffi::{
alloc_buffer, alloc_string, build_object_shape, get_handle, js_array_get, js_array_length,
js_object_alloc_with_shape, js_object_set_field, read_buffer_bytes, read_bytes, read_string,
register_handle, spawn_blocking, ArrayHeader, BufferHeader, Handle, JsPromise, JsString,
- JsValue, ObjectHeader, Promise, StringHeader,
+ JsValue, Promise, StringHeader, TransientRootScope,
};
use std::io::Cursor;
@@ -23,7 +23,7 @@ mod test_async_shims;
extern "C" {
fn js_get_string_pointer_unified(value: f64) -> i64;
fn js_buffer_is_buffer(ptr: i64) -> i32;
- fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64;
+ fn js_object_get_field_by_name_boxed(receiver: f64, key: *const StringHeader) -> f64;
fn js_string_from_bytes(data: *const u8, len: u32) -> *mut StringHeader;
}
@@ -31,16 +31,11 @@ extern "C" {
/// `None` if `opts` isn't an object or the field isn't a number. Handles both
/// int32- and f64-boxed numbers.
unsafe fn opts_number_field(opts: f64, name: &str) -> Option {
- let jv = JsValue::from_bits(opts.to_bits());
- if !jv.is_pointer() {
- return None;
- }
- let obj = jv.as_pointer::();
- if obj.is_null() {
- return None;
- }
+ let scope = TransientRootScope::enter();
+ let rooted_opts = scope.root_nanbox(opts);
let key = js_string_from_bytes(name.as_ptr(), name.len() as u32);
- let field = JsValue::from_bits(js_object_get_field_by_name_f64(obj, key).to_bits());
+ let field =
+ JsValue::from_bits(js_object_get_field_by_name_boxed(rooted_opts.get(), key).to_bits());
if field.is_int32() {
Some(((field.bits() & 0xFFFF_FFFF) as u32 as i32) as f64)
} else if field.is_number() {
@@ -54,16 +49,12 @@ unsafe fn opts_number_field(opts: f64, name: &str) -> Option {
/// object field (e.g. `extend({ background })`). `None` if `obj` isn't an
/// object.
unsafe fn opts_field_bits(opts: f64, name: &str) -> Option {
- let jv = JsValue::from_bits(opts.to_bits());
- if !jv.is_pointer() {
- return None;
- }
- let obj = jv.as_pointer::();
- if obj.is_null() {
- return None;
- }
+ let scope = TransientRootScope::enter();
+ let rooted_opts = scope.root_nanbox(opts);
let key = js_string_from_bytes(name.as_ptr(), name.len() as u32);
- Some(js_object_get_field_by_name_f64(obj, key))
+ let field =
+ JsValue::from_bits(js_object_get_field_by_name_boxed(rooted_opts.get(), key).to_bits());
+ (!field.is_undefined()).then(|| f64::from_bits(field.bits()))
}
/// Read a `{ r, g, b, alpha }` background colour from `opts.background`.
@@ -74,8 +65,14 @@ unsafe fn read_background(opts: f64) -> image::Rgba {
Some(b) => b,
None => return image::Rgba([0, 0, 0, 255]),
};
- let chan = |n: &str, d: f64| opts_number_field(bg, n).unwrap_or(d).clamp(0.0, 255.0) as u8;
- let alpha = (opts_number_field(bg, "alpha")
+ let scope = TransientRootScope::enter();
+ let rooted_bg = scope.root_nanbox(bg);
+ let chan = |n: &str, d: f64| {
+ opts_number_field(rooted_bg.get(), n)
+ .unwrap_or(d)
+ .clamp(0.0, 255.0) as u8
+ };
+ let alpha = (opts_number_field(rooted_bg.get(), "alpha")
.unwrap_or(1.0)
.clamp(0.0, 1.0)
* 255.0)
@@ -224,13 +221,82 @@ fn decode_image_bytes(bytes: &[u8]) -> Handle {
}
}
-/// `sharp(input)` factory — `input` is a file path string OR a Buffer /
-/// Uint8Array of encoded image bytes. The arg arrives as raw NaN-box bits
-/// (NA_JSV); recover the underlying pointer and branch on the Buffer registry
-/// probe.
+const MAX_CREATE_DIMENSION: f64 = 100_000_000.0;
+const MAX_CREATE_PIXELS: usize = 0x3FFF * 0x3FFF;
+
+fn valid_create_dimension(value: f64) -> Option {
+ (value.is_finite() && value.fract() == 0.0 && (1.0..=MAX_CREATE_DIMENSION).contains(&value))
+ .then_some(value as u32)
+}
+
+fn create_solid_image(
+ width: u32,
+ height: u32,
+ channels: u8,
+ background: image::Rgba,
+) -> Option {
+ let pixel_count = (width as usize).checked_mul(height as usize)?;
+ if pixel_count > MAX_CREATE_PIXELS {
+ return None;
+ }
+ let byte_len = pixel_count.checked_mul(channels as usize)?;
+ let mut pixels = Vec::new();
+ pixels.try_reserve_exact(byte_len).ok()?;
+ pixels.resize(byte_len, 0);
+
+ match channels {
+ 3 => {
+ for pixel in pixels.as_chunks_mut::<3>().0 {
+ pixel.copy_from_slice(&background.0[..3]);
+ }
+ image::RgbImage::from_raw(width, height, pixels).map(DynamicImage::ImageRgb8)
+ }
+ 4 => {
+ for pixel in pixels.as_chunks_mut::<4>().0 {
+ pixel.copy_from_slice(&background.0);
+ }
+ image::RgbaImage::from_raw(width, height, pixels).map(DynamicImage::ImageRgba8)
+ }
+ _ => None,
+ }
+}
+
+/// Decode sharp's object-form input descriptor:
+/// `{ create: { width, height, channels, background: { r, g, b, alpha? } } }`.
+///
+/// Sharp accepts only 3-channel RGB or 4-channel RGBA solid backgrounds. The
+/// dimension bounds and default pixel limit mirror its constructor checks.
+unsafe fn create_image_from_input(input: f64) -> Option {
+ let scope = TransientRootScope::enter();
+ let rooted_input = scope.root_nanbox(input);
+ let create = opts_field_bits(rooted_input.get(), "create")?;
+ if !JsValue::from_bits(create.to_bits()).is_pointer() {
+ return None;
+ }
+ let rooted_create = scope.root_nanbox(create);
+
+ let width = valid_create_dimension(opts_number_field(rooted_create.get(), "width")?)?;
+ let height = valid_create_dimension(opts_number_field(rooted_create.get(), "height")?)?;
+ let channels = opts_number_field(rooted_create.get(), "channels")?;
+ if !channels.is_finite() || channels.fract() != 0.0 || !matches!(channels as u8, 3 | 4) {
+ return None;
+ }
+
+ let background = opts_field_bits(rooted_create.get(), "background")?;
+ if !JsValue::from_bits(background.to_bits()).is_pointer() {
+ return None;
+ }
+ let rgba = read_background(rooted_create.get());
+ create_solid_image(width, height, channels as u8, rgba)
+}
+
+/// `sharp(input)` factory — `input` is a file path string, a Buffer /
+/// Uint8Array of encoded image bytes, or a `{ create: { ... } }` descriptor.
+/// The arg arrives as raw NaN-box bits (NA_JSV); recover the underlying pointer
+/// and branch on the input representation.
///
/// # Safety
-/// `input_bits` must be the raw NaN-box bits of a JS string or Buffer value.
+/// `input_bits` must be the raw NaN-box bits of a supported JS input value.
#[no_mangle]
pub unsafe extern "C" fn js_sharp_from_input(input_bits: i64) -> Handle {
let ptr = js_get_string_pointer_unified(f64::from_bits(input_bits as u64));
@@ -243,14 +309,17 @@ pub unsafe extern "C" fn js_sharp_from_input(input_bits: i64) -> Handle {
None => -1,
};
}
- // A POINTER_TAG value that isn't a registered Buffer is a plain object /
- // array — not a valid sharp input. `js_get_string_pointer_unified` hands
- // back its heap pointer, which must NOT be read as a `StringHeader` (that
- // would read arbitrary memory). Reject it the way sharp rejects an
- // unsupported input. (Strings — long or short — and number-coerced keys
- // are not `POINTER_TAG`, so the path-string case still flows through.)
- if JsValue::from_bits(input_bits as u64).is_pointer() {
- return -1;
+ let input = JsValue::from_bits(input_bits as u64);
+ if input.is_pointer() {
+ return match create_image_from_input(f64::from_bits(input.bits())) {
+ Some(image) => register_handle(SharpHandle {
+ image,
+ format: ImageFormat::Png,
+ quality: 80,
+ orientation: 1,
+ }),
+ None => -1,
+ };
}
match read_string(JsString::from_raw(ptr as *mut StringHeader)) {
Some(path) => open_image_path(path),
@@ -839,6 +908,35 @@ mod tests {
use super::*;
use image::{ImageBuffer, Rgba};
+ unsafe fn object(fields: &[(&str, JsValue)]) -> JsValue {
+ let keys: Vec<&str> = fields.iter().map(|(key, _)| *key).collect();
+ let (packed, shape_id) = build_object_shape(&keys);
+ let obj = js_object_alloc_with_shape(
+ shape_id,
+ fields.len() as u32,
+ packed.as_ptr(),
+ packed.len() as u32,
+ );
+ for (index, (_, value)) in fields.iter().enumerate() {
+ js_object_set_field(obj, index as u32, *value);
+ }
+ JsValue::from_object_ptr(obj)
+ }
+
+ unsafe fn create_input(
+ width: JsValue,
+ height: JsValue,
+ channels: JsValue,
+ background: Option,
+ ) -> JsValue {
+ let mut fields = vec![("width", width), ("height", height), ("channels", channels)];
+ if let Some(background) = background {
+ fields.push(("background", background));
+ }
+ let create = object(&fields);
+ object(&[("create", create)])
+ }
+
fn make_handle(w: u32, h: u32) -> Handle {
let buf: ImageBuffer, Vec> =
ImageBuffer::from_pixel(w, h, Rgba([255, 0, 0, 255]));
@@ -896,6 +994,86 @@ mod tests {
assert_eq!(js_sharp_height(-1), 0.0);
}
+ #[test]
+ fn create_input_builds_rgb_canvas() {
+ unsafe {
+ let background = object(&[
+ ("r", JsValue::from_int32(1)),
+ ("g", JsValue::from_number(2.0)),
+ ("b", JsValue::from_int32(3)),
+ ]);
+ let input = create_input(
+ JsValue::from_int32(4),
+ JsValue::from_number(3.0),
+ JsValue::from_int32(3),
+ Some(background),
+ );
+
+ let handle = js_sharp_from_input(input.bits() as i64);
+ let sharp = get_handle::(handle).expect("valid create handle");
+ assert_eq!(sharp.image.dimensions(), (4, 3));
+ assert_eq!(sharp.image.color().channel_count(), 3);
+ assert_eq!(sharp.image.to_rgb8().get_pixel(3, 2).0, [1, 2, 3]);
+ }
+ }
+
+ #[test]
+ fn create_input_preserves_rgba_alpha() {
+ unsafe {
+ let background = object(&[
+ ("r", JsValue::from_int32(10)),
+ ("g", JsValue::from_int32(20)),
+ ("b", JsValue::from_int32(30)),
+ ("alpha", JsValue::from_number(0.5)),
+ ]);
+ let input = create_input(
+ JsValue::from_int32(2),
+ JsValue::from_int32(1),
+ JsValue::from_int32(4),
+ Some(background),
+ );
+
+ let handle = js_sharp_from_input(input.bits() as i64);
+ let sharp = get_handle::(handle).expect("valid create handle");
+ assert_eq!(sharp.image.color().channel_count(), 4);
+ assert_eq!(sharp.image.to_rgba8().get_pixel(1, 0).0, [10, 20, 30, 128]);
+ }
+ }
+
+ #[test]
+ fn create_input_rejects_invalid_descriptors() {
+ unsafe {
+ let background = object(&[
+ ("r", JsValue::from_int32(1)),
+ ("g", JsValue::from_int32(2)),
+ ("b", JsValue::from_int32(3)),
+ ]);
+ let invalid_width = create_input(
+ JsValue::from_number(1.5),
+ JsValue::from_int32(4),
+ JsValue::from_int32(3),
+ Some(background),
+ );
+ assert_eq!(js_sharp_from_input(invalid_width.bits() as i64), -1);
+
+ let invalid_channels = create_input(
+ JsValue::from_int32(4),
+ JsValue::from_int32(4),
+ JsValue::from_int32(2),
+ Some(background),
+ );
+ assert_eq!(js_sharp_from_input(invalid_channels.bits() as i64), -1);
+
+ let missing_background = create_input(
+ JsValue::from_int32(4),
+ JsValue::from_int32(4),
+ JsValue::from_int32(3),
+ None,
+ );
+ assert_eq!(js_sharp_from_input(missing_background.bits() as i64), -1);
+ }
+ }
+
#[test]
fn invalid_handle_async_failure_is_an_error_object() {
let promise = js_sharp_metadata(perry_ffi::INVALID_HANDLE);
diff --git a/crates/perry-ext-sharp/src/test_async_shims.rs b/crates/perry-ext-sharp/src/test_async_shims.rs
index a5b2afdab1..f91f07aae5 100644
--- a/crates/perry-ext-sharp/src/test_async_shims.rs
+++ b/crates/perry-ext-sharp/src/test_async_shims.rs
@@ -1,6 +1,6 @@
//! Test-only host shims for the standalone sharp extension test binary.
-use perry_ffi::Promise;
+use perry_ffi::{NativeAsyncCompletion, Promise};
use std::ffi::c_void;
#[no_mangle]
@@ -8,16 +8,29 @@ pub extern "C" fn perry_ffi_promise_new() -> *mut Promise {
perry_runtime::promise::js_promise_new() as *mut Promise
}
+#[no_mangle]
+pub extern "C" fn perry_ffi_promise_resolve_bits(promise: *mut Promise, bits: u64) {
+ perry_runtime::promise::js_promise_resolve(
+ promise as *mut perry_runtime::Promise,
+ f64::from_bits(bits),
+ );
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64) {
+ perry_runtime::promise::js_promise_reject(
+ promise as *mut perry_runtime::Promise,
+ f64::from_bits(bits),
+ );
+}
+
#[no_mangle]
pub extern "C" fn perry_ffi_promise_resolve_deferred(
promise: *mut Promise,
ctx: *mut c_void,
invoke: extern "C" fn(*mut c_void) -> u64,
) {
- perry_runtime::promise::js_promise_resolve(
- promise as *mut perry_runtime::Promise,
- f64::from_bits(invoke(ctx)),
- );
+ perry_ffi_promise_resolve_bits(promise, invoke(ctx));
}
#[no_mangle]
@@ -26,13 +39,72 @@ pub extern "C" fn perry_ffi_promise_reject_deferred(
ctx: *mut c_void,
invoke: extern "C" fn(*mut c_void) -> u64,
) {
- perry_runtime::promise::js_promise_reject(
- promise as *mut perry_runtime::Promise,
- f64::from_bits(invoke(ctx)),
- );
+ perry_ffi_promise_reject_bits(promise, invoke(ctx));
}
#[no_mangle]
pub extern "C" fn perry_ffi_spawn_blocking(ctx: *mut c_void, invoke: extern "C" fn(*mut c_void)) {
invoke(ctx);
}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_spawn_blocking_with_reactor(
+ ctx: *mut c_void,
+ invoke: extern "C" fn(*mut c_void),
+) {
+ invoke(ctx);
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_new(_flags: u32) -> *mut NativeAsyncCompletion {
+ std::ptr::null_mut()
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_promise(
+ _token: *mut NativeAsyncCompletion,
+) -> *mut Promise {
+ std::ptr::null_mut()
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_resolve_bits(
+ _token: *mut NativeAsyncCompletion,
+ _bits: u64,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_reject_bits(
+ _token: *mut NativeAsyncCompletion,
+ _bits: u64,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_reject_string(
+ _token: *mut NativeAsyncCompletion,
+ _data: *const u8,
+ _len: usize,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_cancel(_token: *mut NativeAsyncCompletion) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_native_async_attach_handle(
+ _token: *mut NativeAsyncCompletion,
+ _handle_bits: u64,
+ _cleanup_flags: u32,
+) -> i32 {
+ 0
+}
+
+#[no_mangle]
+pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {}
diff --git a/crates/perry-ext-zlib/src/stream.rs b/crates/perry-ext-zlib/src/stream.rs
index f67aaaa771..dc33937822 100644
--- a/crates/perry-ext-zlib/src/stream.rs
+++ b/crates/perry-ext-zlib/src/stream.rs
@@ -20,6 +20,7 @@
use perry_ffi::{
alloc_buffer, alloc_string, gc_register_mutable_root_scanner_named, notify_main_thread,
BufferHeader, ErrorKind, GcRootVisitor, JsClosure, JsValue, RawClosureHeader, StringHeader,
+ TransientRootScope,
};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io::{Read, Write};
@@ -67,6 +68,7 @@ extern "C" {
// synchronously before queuing codec work.
pub(crate) fn js_zlib_validate_callback(callback: f64) -> i64;
fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64;
+ fn js_async_hooks_provider_defer_destroy(async_id: u64, check_turns: u32);
fn js_async_hooks_provider_enter(async_id: u64);
fn js_async_hooks_provider_leave(async_id: u64);
fn js_native_call_method_str_key(
@@ -706,7 +708,9 @@ pub(crate) unsafe fn queue_one_shot_callback(
) where
F: FnOnce(&[u8]) -> std::io::Result>,
{
- let callback = js_zlib_validate_callback(callback_value);
+ let scope = TransientRootScope::enter();
+ let callback_value = scope.root_nanbox(callback_value);
+ let _ = js_zlib_validate_callback(callback_value.get());
let data_bits = data_value.to_bits() as i64;
js_zlib_validate_buffer_arg(data_bits);
let result = match read_input_from_bits(data_bits) {
@@ -716,6 +720,10 @@ pub(crate) unsafe fn queue_one_shot_callback(
ensure_aux_pump_registered();
ensure_gc_scanner_registered();
let async_id = js_async_hooks_provider_init(b"ZLIB".as_ptr(), b"ZLIB".len());
+ // Provider init delivers user hooks and may move the callback. Re-read the
+ // rooted value only after it returns, immediately before publishing it in
+ // the scanned pending queue.
+ let callback = js_zlib_validate_callback(callback_value.get());
statics()
.lock()
.unwrap()
@@ -1337,6 +1345,7 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 {
};
count += 1;
let event_async_id = event_stream_handle(&ev).map(stream_async_id).unwrap_or(0);
+ let mut destroy_after_dispatch = 0;
if event_async_id != 0 {
js_async_hooks_provider_enter(event_async_id);
}
@@ -1368,7 +1377,10 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 {
}
}
ZlibEvent::Finish(id) => {
- for cb in listeners_for(id, "finish") {
+ let scope = TransientRootScope::enter();
+ let callbacks = scope.root_addrs(&listeners_for(id, "finish"));
+ for cb in callbacks {
+ let cb = cb.get();
if cb != 0 {
let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0();
}
@@ -1422,6 +1434,7 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 {
}
}
drop_buffered_stream(&mut statics().lock().unwrap(), id);
+ destroy_after_dispatch = event_async_id;
}
ZlibEvent::Callback(cb) => {
if cb != 0 {
@@ -1429,11 +1442,19 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 {
}
}
ZlibEvent::OneShotCallback(cb, result, async_id) => {
+ let scope = TransientRootScope::enter();
+ let callback = scope.root_addr(cb);
+ // Node exposes the native codec completion and delivery of the
+ // JavaScript callback as two phases of the same ZLIB resource.
js_async_hooks_provider_enter(async_id);
js_async_hooks_provider_leave(async_id);
js_async_hooks_provider_enter(async_id);
- call_one_shot_callback(cb, result);
+ call_one_shot_callback(callback.get(), result);
js_async_hooks_provider_leave(async_id);
+ // This is queued before the callback's Promise continuation
+ // schedules its first user immediate, so zlib needs one more
+ // check turn than synchronously closed handles.
+ js_async_hooks_provider_defer_destroy(async_id, 4);
}
ZlibEvent::Error(id, msg) => {
let err_f64 = build_error_object(&msg);
@@ -1443,11 +1464,15 @@ pub unsafe extern "C" fn js_ext_zlib_process_pending() -> i32 {
}
}
drop_buffered_stream(&mut statics().lock().unwrap(), id);
+ destroy_after_dispatch = event_async_id;
}
}
if event_async_id != 0 {
js_async_hooks_provider_leave(event_async_id);
}
+ if destroy_after_dispatch != 0 {
+ js_async_hooks_provider_defer_destroy(destroy_after_dispatch, 4);
+ }
}
count
}
diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml
index f02ee16750..f948f9752f 100644
--- a/crates/perry-runtime/Cargo.toml
+++ b/crates/perry-runtime/Cargo.toml
@@ -391,6 +391,9 @@ mach2 = "0.6"
# See `build.rs` and issue #395 for the rationale.
[build-dependencies]
perry-dispatch = { path = "../perry-dispatch" }
+# Build-time only: fingerprints the compiler/runtime source contract embedded
+# in libperry_runtime so the CLI can reject a stale archive before linking.
+sha2 = "0.11"
# Build-time only (does NOT ship in the runtime binary): generates the WHATWG
# single-byte TextDecoder index tables so they are always spec-accurate. Only
# the generated `[u16; 128]` arrays land in the binary. Already vetted in the
diff --git a/crates/perry-runtime/build.rs b/crates/perry-runtime/build.rs
index f33d16410c..122a29e245 100644
--- a/crates/perry-runtime/build.rs
+++ b/crates/perry-runtime/build.rs
@@ -48,8 +48,172 @@
//! line — see `src/stub_diag.rs` for the env-var policy.
use perry_dispatch::{ArgKind, MethodRow, ReturnKind};
+use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fmt::Write;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+/// Source trees that define the compiler <-> runtime contract. A clean git
+/// checkout uses the commit as its build id; dirty/source-only builds hash
+/// these inputs so rebuilding the compiler without rebuilding the archive is
+/// still detected even though the package version did not change.
+const RUNTIME_BUILD_INPUTS: &[&str] = &[
+ "Cargo.toml",
+ "Cargo.lock",
+ "crates/perry-dispatch/Cargo.toml",
+ "crates/perry-dispatch/src",
+ "crates/perry/Cargo.toml",
+ "crates/perry/src",
+ "crates/perry-codegen/Cargo.toml",
+ "crates/perry-codegen/src",
+ "crates/perry-hir/Cargo.toml",
+ "crates/perry-hir/src",
+ "crates/perry-transform/Cargo.toml",
+ "crates/perry-transform/src",
+ "crates/perry-runtime/Cargo.toml",
+ "crates/perry-runtime/build.rs",
+ "crates/perry-runtime/src",
+];
+
+/// `cargo package` builds the crate from an isolated directory without the
+/// workspace siblings above. Hash the packaged runtime itself in that layout;
+/// the compiler and static wrapper both consume this same crate artifact.
+const PACKAGED_RUNTIME_BUILD_INPUTS: &[&str] = &["Cargo.toml", "build.rs", "src"];
+
+fn command_stdout(root: &Path, args: &[&str]) -> Option {
+ let output = Command::new("git")
+ .arg("-C")
+ .arg(root)
+ .args(args)
+ .output()
+ .ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
+}
+
+fn sanitize_build_id(value: &str) -> String {
+ value
+ .chars()
+ .take(128)
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':') {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+fn collect_source_files(root: &Path, path: &Path, out: &mut Vec) {
+ let Ok(metadata) = std::fs::metadata(path) else {
+ return;
+ };
+ if metadata.is_file() {
+ out.push(path.to_path_buf());
+ return;
+ }
+ let Ok(entries) = std::fs::read_dir(path) else {
+ return;
+ };
+ let mut entries: Vec<_> = entries.flatten().collect();
+ entries.sort_by_key(|entry| entry.file_name());
+ for entry in entries {
+ let child = entry.path();
+ if child.strip_prefix(root).ok().is_some_and(|relative| {
+ relative
+ .components()
+ .any(|part| part.as_os_str() == "target" || part.as_os_str() == ".git")
+ }) {
+ continue;
+ }
+ collect_source_files(root, &child, out);
+ }
+}
+
+fn source_build_id(root: &Path, inputs: &[&str]) -> String {
+ let mut files = Vec::new();
+ for relative in inputs {
+ collect_source_files(root, &root.join(relative), &mut files);
+ }
+ files.sort();
+ files.dedup();
+
+ let mut hasher = Sha256::new();
+ hasher.update(b"perry-runtime-build-inputs-v1\0");
+ for path in files {
+ println!("cargo:rerun-if-changed={}", path.display());
+ let relative = path.strip_prefix(root).unwrap_or(&path);
+ hasher.update(relative.to_string_lossy().replace('\\', "/").as_bytes());
+ hasher.update(b"\0");
+ match std::fs::read(&path) {
+ Ok(bytes) => {
+ hasher.update((bytes.len() as u64).to_le_bytes());
+ hasher.update(bytes);
+ }
+ Err(_) => hasher.update(b"unreadable"),
+ }
+ hasher.update(b"\0");
+ }
+ let mut hex = String::with_capacity(64);
+ for byte in hasher.finalize() {
+ write!(hex, "{byte:02x}").expect("write source fingerprint");
+ }
+ format!("src:{hex}")
+}
+
+fn emit_runtime_build_id() {
+ println!("cargo:rerun-if-env-changed=PERRY_BUILD_COMMIT");
+ let manifest_dir =
+ PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
+ let workspace_candidate = manifest_dir.join("../..");
+ let workspace_layout = workspace_candidate
+ .join("crates/perry-runtime/Cargo.toml")
+ .is_file();
+ let (root, inputs) = if workspace_layout {
+ (workspace_candidate, RUNTIME_BUILD_INPUTS)
+ } else {
+ (manifest_dir, PACKAGED_RUNTIME_BUILD_INPUTS)
+ };
+ let root = root.canonicalize().unwrap_or(root);
+
+ // Make branch/commit changes rerun this build script even when the source
+ // files themselves are byte-identical (for example after a rebase).
+ if workspace_layout {
+ if let Some(git_head) = command_stdout(&root, &["rev-parse", "--git-path", "HEAD"]) {
+ println!("cargo:rerun-if-changed={}", root.join(git_head).display());
+ }
+ if let Some(symbolic_ref) = command_stdout(&root, &["symbolic-ref", "-q", "HEAD"]) {
+ if let Some(git_ref) =
+ command_stdout(&root, &["rev-parse", "--git-path", &symbolic_ref])
+ {
+ println!("cargo:rerun-if-changed={}", root.join(git_ref).display());
+ }
+ }
+ }
+
+ let explicit = std::env::var("PERRY_BUILD_COMMIT")
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ .map(|value| format!("git:{}", sanitize_build_id(value.trim())));
+
+ let source_id = source_build_id(&root, inputs);
+ let clean_commit = workspace_layout
+ .then(|| command_stdout(&root, &["rev-parse", "--verify", "HEAD"]))
+ .flatten()
+ .filter(|_| {
+ let mut args = vec!["status", "--porcelain", "--untracked-files=normal", "--"];
+ args.extend_from_slice(inputs);
+ command_stdout(&root, &args).is_some_and(|status| status.is_empty())
+ })
+ .map(|commit| format!("git:{}", sanitize_build_id(&commit)));
+
+ let build_id = explicit.or(clean_commit).unwrap_or(source_id);
+ println!("cargo:rustc-env=PERRY_RUNTIME_BUILD_ID={build_id}");
+}
fn arg_kind_rust_type(k: ArgKind) -> &'static str {
match k {
@@ -372,6 +536,7 @@ fn generate_single_byte_encodings(out_dir: &str) {
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../perry-dispatch/src/lib.rs");
+ emit_runtime_build_id();
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
generate_single_byte_encodings(&out_dir);
diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs
index d6a25b9343..efde64e781 100644
--- a/crates/perry-runtime/src/array/indexing.rs
+++ b/crates/perry-runtime/src/array/indexing.rs
@@ -1084,10 +1084,11 @@ pub extern "C" fn js_array_set_f64(arr: *mut ArrayHeader, index: u32, value: f64
/// (`index_set` / `index` / `field_set_by_name`) routes here.
/// test262 built-ins/Array element/add on frozen|sealed|non-extensible.
/// Strict-mode guard for a would-be `arr[index] = v` element write: throws the
-/// spec `Set`-with-`Throw` TypeError when `arr` is frozen (existing index →
-/// read-only) or non-extensible and the index is new (→ not-extensible). No-op
-/// for writable slots, buffers, and typed arrays (which own their store
-/// semantics). Shared by the strict element-write entry points.
+/// spec `Set`-with-`Throw` TypeError when an own data descriptor is read-only,
+/// an accessor has no setter, `length` is read-only and would grow, the array
+/// is frozen, or a non-extensible array would gain a new element. No-op for
+/// writable slots, buffers, and typed arrays (which own their store semantics).
+/// Shared by the strict element-write entry points.
#[inline]
pub(crate) fn array_strict_index_write_guard(arr: *mut ArrayHeader, index: u32) {
let clean = clean_arr_ptr_mut(arr);
@@ -1099,12 +1100,49 @@ pub(crate) fn array_strict_index_write_guard(arr: *mut ArrayHeader, index: u32)
}
let flags = array_object_flags(clean);
let length = unsafe { (*clean).length };
+
+ // A descriptor-bearing array is rare, so keep all key construction and
+ // side-table probes off the ordinary dense-array path. An accessor with a
+ // setter remains writable even when the object is frozen; return early and
+ // let `js_array_set_f64_extend` invoke it. Every other rejected descriptor
+ // must throw here because that lower-level helper deliberately retains a
+ // silent contract for internal DefineOwnProperty callers.
+ if flags & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 {
+ let key = index.to_string();
+ if let Some(accessor) = crate::object::get_accessor_descriptor(clean as usize, &key) {
+ if accessor.set == 0 {
+ throw_frozen_array_index_write(index);
+ }
+ return;
+ }
+ if crate::object::get_property_attrs(clean as usize, &key)
+ .is_some_and(|attrs| !attrs.writable())
+ {
+ throw_frozen_array_index_write(index);
+ }
+ if index >= length
+ && crate::object::get_property_attrs(clean as usize, "length")
+ .is_some_and(|attrs| !attrs.writable())
+ {
+ crate::collection_iter::throw_type_error(
+ "Cannot assign to read only property 'length' of object '[object Array]'",
+ );
+ }
+ }
+
if index < length {
- // Existing index: only a *frozen* array's data is non-writable; a
- // sealed / non-extensible array still permits overwriting it.
if flags & crate::gc::OBJ_FLAG_FROZEN != 0 {
throw_frozen_array_index_write(index);
}
+ // `length` includes holes. Filling one creates a new own property, so
+ // sealed/preventExtensions arrays must reject it even though the index
+ // is numerically in bounds. This probe is confined to the already-cold
+ // restricted-object branch.
+ if flags & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) != 0
+ && !unsafe { array_has_own_index(clean, index) }
+ {
+ throw_array_not_extensible_add(index);
+ }
} else if flags
& (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND)
!= 0
diff --git a/crates/perry-runtime/src/async_context.rs b/crates/perry-runtime/src/async_context.rs
index 53567fcdd0..fa80f61c1c 100644
--- a/crates/perry-runtime/src/async_context.rs
+++ b/crates/perry-runtime/src/async_context.rs
@@ -309,6 +309,14 @@ pub fn clear_store(handle: i64) {
.entries
.iter()
.any(|entry| entry.handle == handle)
+ }) || CONTEXT_GUARDS.with(|guards| {
+ guards.borrow().iter().any(|guard| {
+ matches!(
+ &guard.action,
+ ContextGuardAction::RestoreStores(saved_handle, Some(_))
+ if *saved_handle == handle
+ )
+ })
});
if was_active {
HANDLE_GENERATIONS.with(|generations| {
diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs
index 9b4ddc0e52..961190f9c1 100644
--- a/crates/perry-runtime/src/async_hooks.rs
+++ b/crates/perry-runtime/src/async_hooks.rs
@@ -19,6 +19,14 @@ use crate::object::{js_object_get_field_by_name, ObjectHeader};
use crate::string::{js_string_from_bytes, StringHeader};
use crate::value::{JSValue, POINTER_MASK};
+mod provider_ffi;
+pub use provider_ffi::{
+ defer_destroy_after_check_turns, js_async_hooks_provider_defer_destroy,
+ js_async_hooks_provider_destroy, js_async_hooks_provider_enter, js_async_hooks_provider_init,
+ js_async_hooks_provider_init_with_trigger, js_async_hooks_provider_leave,
+ js_async_hooks_provider_run_catching, js_async_hooks_provider_run_catching_with_this,
+};
+
const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
const STRING_TAG: u64 = 0x7FFF_0000_0000_0000;
const TAG_MASK: u64 = 0xFFFF_0000_0000_0000;
@@ -623,12 +631,21 @@ fn with_hook_callbacks(
.iter()
.map(|callbacks| scope.root_raw_const_ptr(callbacks.for_phase(phase)))
.collect();
+ let mut thrown = None;
for callback in rooted {
- callback.with_const_ptr::(|callback| {
+ let outcome = callback.with_const_ptr::(|callback| {
if !callback.is_null() {
- f(callback);
+ return crate::exception::js_call_catching(|| {
+ f(callback);
+ f64::from_bits(crate::value::TAG_UNDEFINED)
+ });
}
+ Ok(f64::from_bits(crate::value::TAG_UNDEFINED))
});
+ if let Err(error) = outcome {
+ thrown = Some(scope.root_nanbox_f64(error));
+ break;
+ }
}
let outermost = HOOK_CALLBACK_DEPTH.with(|depth| {
let next = depth.get().saturating_sub(1);
@@ -641,6 +658,9 @@ fn with_hook_callbacks(
set_hook_enabled(index, enabled);
}
}
+ if let Some(error) = thrown {
+ crate::exception::js_throw(error.get_nanbox_f64());
+ }
}
/// Model the Promise that Node uses to evaluate an ESM entry module. Perry's
@@ -910,66 +930,6 @@ pub fn run_resource_scope(ids: AsyncResourceIds, completion: impl FnOnce()) {
leave_resource_scope(ids.async_id);
}
-/// C ABI used by separately-linked native providers such as perry-ext-zlib.
-#[no_mangle]
-pub unsafe extern "C" fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64 {
- if type_ptr.is_null() {
- return 0;
- }
- let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len));
- let resource = crate::object::js_object_alloc_null_proto(0, 0);
- init_resource(
- type_name,
- crate::value::js_nanbox_pointer(resource as i64),
- true,
- )
- .async_id
-}
-
-#[no_mangle]
-pub unsafe extern "C" fn js_async_hooks_provider_init_with_trigger(
- type_ptr: *const u8,
- type_len: usize,
- trigger_async_id: u64,
-) -> u64 {
- if type_ptr.is_null() {
- return 0;
- }
- let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len));
- let resource = crate::object::js_object_alloc_null_proto(0, 0);
- init_resource_with_trigger(
- type_name,
- crate::value::js_nanbox_pointer(resource as i64),
- true,
- trigger_async_id,
- )
- .async_id
-}
-
-#[no_mangle]
-pub extern "C" fn js_async_hooks_provider_enter(async_id: u64) {
- let trigger_async_id = RESOURCES
- .lock()
- .unwrap()
- .get(&async_id)
- .map(|meta| meta.trigger_async_id)
- .unwrap_or(0);
- enter_resource_scope(AsyncResourceIds {
- async_id,
- trigger_async_id,
- });
-}
-
-#[no_mangle]
-pub extern "C" fn js_async_hooks_provider_leave(async_id: u64) {
- leave_resource_scope(async_id);
-}
-
-#[no_mangle]
-pub extern "C" fn js_async_hooks_provider_destroy(async_id: u64) {
- destroy(async_id);
-}
-
pub fn enqueue_gc_destroy(async_id: u64) {
if async_id != 0 {
GC_DESTROY_QUEUE.lock().unwrap().push_back(async_id);
@@ -1476,7 +1436,6 @@ pub fn try_async_resource_method_dispatch(
) {
return None;
}
- let handle = resolve_async_resource_handle(receiver)?;
let scope = crate::gc::RuntimeHandleScope::new();
let raw_args: Vec = if args_ptr.is_null() || args_len == 0 {
Vec::new()
@@ -1484,6 +1443,7 @@ pub fn try_async_resource_method_dispatch(
unsafe { std::slice::from_raw_parts(args_ptr, args_len).to_vec() }
};
let arg_handles = scope.root_nanbox_f64_slice(&raw_args);
+ let handle = resolve_async_resource_handle(receiver)?;
let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles);
Some(match method_name {
"asyncId" => js_async_resource_async_id(handle),
diff --git a/crates/perry-runtime/src/async_hooks/provider_ffi.rs b/crates/perry-runtime/src/async_hooks/provider_ffi.rs
new file mode 100644
index 0000000000..b50e7f3657
--- /dev/null
+++ b/crates/perry-runtime/src/async_hooks/provider_ffi.rs
@@ -0,0 +1,168 @@
+//! Exception-safe callback bridges for separately linked async providers.
+
+use super::{
+ destroy, enter_resource_scope, init_resource, init_resource_with_trigger, leave_resource_scope,
+ AsyncResourceIds, RESOURCES,
+};
+
+extern "C" fn deferred_destroy_step(closure: *const crate::closure::ClosureHeader) -> f64 {
+ let async_id = crate::closure::js_closure_get_capture_f64(closure, 0) as u64;
+ let remaining = crate::closure::js_closure_get_capture_f64(closure, 1) as u32;
+ if remaining == 0 {
+ destroy(async_id);
+ } else {
+ schedule_deferred_destroy_step(async_id, remaining - 1);
+ }
+ f64::from_bits(crate::value::TAG_UNDEFINED)
+}
+
+fn schedule_deferred_destroy_step(async_id: u64, remaining: u32) {
+ crate::closure::js_register_closure_arity(deferred_destroy_step as *const u8, 0);
+ let scope = crate::gc::RuntimeHandleScope::new();
+ let callback = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc(
+ deferred_destroy_step as *const u8,
+ 2,
+ ));
+ callback.with_mut_ptr(|callback| {
+ crate::closure::js_closure_set_capture_f64(callback, 0, async_id as f64);
+ crate::closure::js_closure_set_capture_f64(callback, 1, remaining as f64);
+ crate::timer::js_set_immediate_callback(callback as i64);
+ });
+}
+
+/// Retire a native provider after a fixed number of check phases. libuv handle
+/// close callbacks do not fire synchronously with APIs such as `unwatchFile`
+/// or one-shot zlib completion, so their destroy hooks must remain observable
+/// only after the corresponding close turns have run.
+pub fn defer_destroy_after_check_turns(async_id: u64, check_turns: u32) {
+ if async_id == 0 {
+ return;
+ }
+ if check_turns == 0 {
+ destroy(async_id);
+ } else {
+ schedule_deferred_destroy_step(async_id, check_turns - 1);
+ }
+}
+
+/// C ABI used by separately-linked native providers such as perry-ext-zlib.
+#[no_mangle]
+pub unsafe extern "C" fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64 {
+ if type_ptr.is_null() {
+ return 0;
+ }
+ let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len));
+ let resource = crate::object::js_object_alloc_null_proto(0, 0);
+ init_resource(
+ type_name,
+ crate::value::js_nanbox_pointer(resource as i64),
+ true,
+ )
+ .async_id
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn js_async_hooks_provider_init_with_trigger(
+ type_ptr: *const u8,
+ type_len: usize,
+ trigger_async_id: u64,
+) -> u64 {
+ if type_ptr.is_null() {
+ return 0;
+ }
+ let type_name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len));
+ let resource = crate::object::js_object_alloc_null_proto(0, 0);
+ init_resource_with_trigger(
+ type_name,
+ crate::value::js_nanbox_pointer(resource as i64),
+ true,
+ trigger_async_id,
+ )
+ .async_id
+}
+
+#[no_mangle]
+pub extern "C" fn js_async_hooks_provider_enter(async_id: u64) {
+ let trigger_async_id = RESOURCES
+ .lock()
+ .unwrap()
+ .get(&async_id)
+ .map(|meta| meta.trigger_async_id)
+ .unwrap_or(0);
+ enter_resource_scope(AsyncResourceIds {
+ async_id,
+ trigger_async_id,
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn js_async_hooks_provider_leave(async_id: u64) {
+ leave_resource_scope(async_id);
+}
+
+#[no_mangle]
+pub extern "C" fn js_async_hooks_provider_destroy(async_id: u64) {
+ destroy(async_id);
+}
+
+#[no_mangle]
+pub extern "C" fn js_async_hooks_provider_defer_destroy(async_id: u64, check_turns: u32) {
+ defer_destroy_after_check_turns(async_id, check_turns);
+}
+
+/// Run an external-provider callback while guaranteeing that the provider
+/// scope is restored before a JS exception resumes unwinding into generated
+/// code. Rust `Drop` guards cannot provide this guarantee because Perry's JS
+/// exception transport deliberately skips runtime Rust cleanup frames.
+#[no_mangle]
+pub unsafe extern "C" fn js_async_hooks_provider_run_catching(
+ async_id: u64,
+ callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64,
+ data: *mut std::ffi::c_void,
+) -> f64 {
+ js_async_hooks_provider_enter(async_id);
+ let outcome = crate::exception::js_call_catching(|| callback(data));
+ let scope = crate::gc::RuntimeHandleScope::new();
+ let (threw, result) = match outcome {
+ Ok(value) => (false, scope.root_nanbox_f64(value)),
+ Err(error) => (true, scope.root_nanbox_f64(error)),
+ };
+ js_async_hooks_provider_leave(async_id);
+ if threw {
+ crate::exception::js_throw(result.get_nanbox_f64());
+ }
+ result.get_nanbox_f64()
+}
+
+/// Provider callback wrapper for external EventEmitter-style dispatch. It
+/// additionally restores implicit `this` and can retire a one-shot provider
+/// before propagating a JavaScript exception.
+#[no_mangle]
+pub unsafe extern "C" fn js_async_hooks_provider_run_catching_with_this(
+ async_id: u64,
+ this_value: f64,
+ destroy_after: i32,
+ callback: unsafe extern "C" fn(*mut std::ffi::c_void) -> f64,
+ data: *mut std::ffi::c_void,
+) -> f64 {
+ let scope = crate::gc::RuntimeHandleScope::new();
+ let this_value = scope.root_nanbox_f64(this_value);
+ js_async_hooks_provider_enter(async_id);
+ let previous_this = scope.root_nanbox_f64(crate::object::js_implicit_this_set(
+ this_value.get_nanbox_f64(),
+ ));
+ let outcome = crate::exception::js_call_catching(|| callback(data));
+ let (threw, result) = match outcome {
+ Ok(value) => (false, scope.root_nanbox_f64(value)),
+ Err(error) => (true, scope.root_nanbox_f64(error)),
+ };
+ crate::object::js_implicit_this_set(previous_this.get_nanbox_f64());
+ js_async_hooks_provider_leave(async_id);
+ if destroy_after != 0 {
+ js_async_hooks_provider_destroy(async_id);
+ }
+ if threw {
+ crate::exception::js_throw(result.get_nanbox_f64());
+ }
+ result.get_nanbox_f64()
+}
diff --git a/crates/perry-runtime/src/build_stamp.rs b/crates/perry-runtime/src/build_stamp.rs
new file mode 100644
index 0000000000..2bcc4e5af2
--- /dev/null
+++ b/crates/perry-runtime/src/build_stamp.rs
@@ -0,0 +1,30 @@
+//! Build identity embedded in every `libperry_runtime` archive.
+//!
+//! The compiler reads this marker before linking. Keeping it in the runtime
+//! crate (rather than a packaging sidecar) means copied archives, Cargo-built
+//! archives, compressed npm archives, and platform-suffixed archives all carry
+//! their identity with them.
+
+/// Revision/fingerprint produced by `build.rs` from the compiler/runtime
+/// contract sources. Clean checkouts use `git:`; dirty or source-only
+/// builds use `src:`.
+pub const PERRY_RUNTIME_BUILD_ID: &str = env!("PERRY_RUNTIME_BUILD_ID");
+
+/// NUL-terminated record deliberately stored as plain ASCII so the CLI can
+/// find it by streaming over either an ar archive (`.a`) or a COFF library
+/// (`.lib`) without invoking platform-specific archive tools.
+pub const PERRY_RUNTIME_BUILD_STAMP: &str = concat!(
+ "PERRY_RUNTIME_BUILD_STAMP_V1|version=",
+ env!("CARGO_PKG_VERSION"),
+ "|build=",
+ env!("PERRY_RUNTIME_BUILD_ID"),
+ "\0",
+);
+
+// `#[used]` keeps both this reference and its string data in the rlib object
+// set copied by perry-runtime-static into libperry_runtime. The symbol stays
+// mangled so linking a stdlib archive that also contains perry-runtime cannot
+// create a duplicate public C symbol.
+#[used]
+#[doc(hidden)]
+pub static PERRY_RUNTIME_BUILD_STAMP_EMBEDDED: &[u8] = PERRY_RUNTIME_BUILD_STAMP.as_bytes();
diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs
index ef4136a0e1..dfca1ece2e 100644
--- a/crates/perry-runtime/src/child_process/reactor.rs
+++ b/crates/perry-runtime/src/child_process/reactor.rs
@@ -201,7 +201,7 @@ static CP_LIVE: Mutex