Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.d/10532-call-arity-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### Fixed

- **Calls with more than 16 arguments: closure-value calls failed to compile, dynamic calls dropped arguments, and `Reflect.apply` forwarded at most four (#10420, #10425).** Calling a function *value* (arrow, `var f = function f`, imported `var`/`const`, rest params) with 17+ arguments was a hard codegen error (`closure call with 18 args (max 16)`) at three `bail!` sites, and the node-submodule call path truncated to 16. qs 6.15.3's recursive 18-argument `stringify` could not compile. Several other ceilings silently dropped arguments:
- the `__perry_wrap_*` function-value wrappers took at most 16 params, while the runtime dispatched the registered full-width ABI into them, so `apply`/`call`/spread/object-literal-method calls of an 18-param function passed `0` for params 17 and 18 (class-method wrappers stopped at 32);
- `dispatch_with_arity` (33+ declared params) and `dispatch_rest_bundled` (16+ fixed params, which includes every `arguments` user with 16+ params) returned `undefined` without calling the body;
- `Reflect.apply` sent every list of four or more arguments through `js_closure_call4`;
- setTimeout/setInterval/`process.nextTick` clamped trailing arguments to nine.

Closure-value call sites now share `lower_call::emit_closure_handle_call`: up to 16 arguments keep the `js_closure_call{N}` fast path (IR unchanged); wider calls marshal an entry-block buffer into `js_closure_call_array`. Wrappers take every declared parameter. Bodies wider than the runtime's exact transmute arms are called through a padded ladder of widths (64/256/1024 slots, `undefined`-filled) in `closure::wide_call`, which relies on the same caller-cleanup ABI property the existing more-args-than-declared transmutes use. Past 1024 slots the call throws `RangeError`. The wide `js_closure_call_array` arm now resolves its route with one memoized strategy probe instead of re-reading the body record, which makes a 20-argument closure-value call 10.5% cheaper in instructions. Reflect.apply, the timers and nextTick pass longer lists to the variadic entry point.

Validation: new gap test `test_gap_10420_call_arity_limits` (17/18/32/64-argument calls across arrow, function-expression, imported and rest callees; apply/call/spread/Reflect.apply/Reflect.construct/bind; class and object-literal methods; `arguments.length`; timers; 3- and 16-argument controls) fails to compile on the baseline and matches Node 26.5.1 here, plus codegen and runtime unit tests. Gap suite: no regressions against the baseline. Instruction counts for 3-argument closure-value loops, `fn.apply` with 5 arguments and `benchmarks/suite/14_closure.ts`/`09_method_calls.ts` are unchanged. qs 6.15.3 compiles from source; its next blocker is #10482.
31 changes: 16 additions & 15 deletions crates/perry-codegen/src/codegen/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,16 +777,17 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
// dead-code elimination at link time will remove unused ones.
for f in &hir.functions {
let original_name = func_names.get(&f.id).cloned().unwrap();
// Wrapper signature: i64 closure_ptr + N doubles for args. Cap at 16 to
// match the `js_closure_call0..16` dispatch family (the closure-call ABI
// tops out at 16 positional args; a function with more must be reached
// via a rest-bundling path). Pre-fix this was capped at 5 with a stale
// "js_closure_call only goes up to 5 args" comment, so any function
// invoked as a closure value (object-literal method, callback, `apply`
// target) with 6+ params silently dropped every argument past the 5th
// — e.g. test262's `TemporalHelpers.assertDuration(d, y, mo, w, d, h, …)`
// (11 args) read `hours` onward as 0.
let arity = f.params.len().min(16);
// Wrapper signature: i64 closure_ptr + one double per declared param —
// the full ABI arity `user_fn_wrapper_arity` registers for dynamic
// dispatch. Any cap here silently drops arguments: at 5, test262's
// `TemporalHelpers.assertDuration(d, y, mo, w, d, h, …)` (11 args) read
// `hours` onward as 0; at 16 (#10420), every `apply`/`call`/spread or
// object-literal-method call of an 18-param function passed `0` for
// params 17 and 18, because the runtime dispatched the registered
// 18-slot signature into a 16-param wrapper. Calls wider than the
// `js_closure_call0..16` family reach the wrapper through
// `js_closure_call_array`.
let arity = f.params.len();
let arg_names: Vec<String> = (0..arity).map(|i| format!("%a{}", i)).collect();
let mut wrap_params: Vec<(LlvmType, String)> = vec![(I64, "%this_closure".to_string())];
for name in &arg_names {
Expand Down Expand Up @@ -839,7 +840,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
continue;
}

let arity = f.params.len().min(16);
let arity = f.params.len();
let mut params: Vec<(LlvmType, String)> = vec![(I64, "%this_closure".to_string())];
params.extend((0..arity).map(|i| (DOUBLE, format!("%a{}", i))));
let alias = llmod.define_function(&alias_wrap, DOUBLE, params);
Expand Down Expand Up @@ -1064,9 +1065,9 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
let target_wrap = format!("__perry_wrap_{}", target);
if !llmod.has_function(&raw_wrap) && emitted_aliases.insert(raw_wrap.clone()) {
if llmod.has_function(&target_wrap) {
// Match the canonical wrapper's closure-call ABI (up to
// 16 positional arguments), including renamed exports.
let arity = function.map(|f| f.params.len().min(16)).unwrap_or(5);
// Match the canonical wrapper's closure-call ABI (one
// double per declared param), including renamed exports.
let arity = function.map(|f| f.params.len()).unwrap_or(5);
let mut params = vec![(I64, "%this_closure".to_string())];
params.extend((0..arity).map(|i| (DOUBLE, format!("%a{}", i))));
let wf = llmod.define_function(&raw_wrap, DOUBLE, params.clone());
Expand Down Expand Up @@ -1234,7 +1235,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> {
if !emitted_wrappers.insert(wrap_name.clone()) {
continue;
}
let arity = method.params.len().min(32);
let arity = method.params.len();
let mut wrap_params: Vec<(LlvmType, String)> = vec![(I64, "%this_closure".to_string())];
for i in 0..arity {
wrap_params.push((DOUBLE, format!("%a{}", i)));
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1494,7 +1494,7 @@ pub(super) fn emit_namespace_populator(
// not be collapsed to `_constructor` here (#7964).
sanitize_member(source_local)
);
let arity = (*param_count).min(16);
let arity = *param_count;
let mut wrapper_params: Vec<crate::types::LlvmType> = vec![I64];
wrapper_params.extend(std::iter::repeat_n(DOUBLE, arity));
ctx.pending_declares
Expand Down
26 changes: 4 additions & 22 deletions crates/perry-codegen/src/expr/static_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,33 +360,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let blk = ctx.block();
unbox_to_i64(blk, closure_box)
};
if lowered.len() <= 16 {
let runtime_fn = format!("js_closure_call{}", lowered.len());
let mut call_args: Vec<(crate::types::LlvmType, &str)> =
vec![(I64, &closure_handle)];
for value in lowered.iter() {
call_args.push((DOUBLE, value.as_str()));
}
return Ok(ctx.block().call(DOUBLE, &runtime_fn, &call_args));
}

// #3527: namespace members backed by exported
// closure getters need the same arbitrary-arity
// array dispatch as ordinary closure values.
// Effect's `Layer.mergeAll` is a rest closure
// and OpenCode passes 18 layers here.
let n = lowered.len();
let buf = ctx.func.alloca_entry_array(DOUBLE, n);
let blk = ctx.block();
for (i, value) in lowered.iter().enumerate() {
let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]);
blk.store(DOUBLE, value, &slot);
}
let argc = n.to_string();
Ok(blk.call(
DOUBLE,
"js_closure_call_array",
&[(I64, &closure_handle), (PTR, &buf), (I64, &argc)],
Ok(crate::lower_call::emit_closure_handle_call(
ctx,
&closure_handle,
&lowered,
))
},
)?;
Expand Down
142 changes: 142 additions & 0 deletions crates/perry-codegen/src/lower_call/closure_call_arity_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//! #10420: calls through a function VALUE have no argument-count ceiling.
//!
//! A closure-typed local called with more than 16 arguments used to fail to
//! compile (`closure call with 18 args (max 16)`), and the `__perry_wrap_*`
//! value wrappers of top-level functions were capped at 16 params, so every
//! dynamic call of an 18-param function (`apply`/`call`/spread/object-literal
//! method) passed `0` for params 17 and 18. Up to 16 arguments must keep the
//! per-arity `js_closure_call{N}` fast path; wider calls marshal a stack buffer
//! into `js_closure_call_array`.

use crate::{compile_module, CompileOptions};
use perry_hir::types::{FunctionType, Type};
use perry_hir::{Expr, Function, Module, Param, Stmt};

fn param(id: u32) -> Param {
Param {
id,
name: format!("p{id}"),
ty: Type::Any,
default: None,
decorators: Vec::new(),
is_rest: false,
arguments_object: None,
}
}

fn params(base: u32, count: usize) -> Vec<Param> {
(0..count as u32).map(|i| param(base + i)).collect()
}

fn function_type(count: usize) -> Type {
Type::Function(FunctionType {
params: (0..count)
.map(|i| (format!("p{i}"), Type::Any, false))
.collect(),
return_type: Box::new(Type::Any),
is_async: false,
is_generator: false,
})
}

fn ir(module: &Module) -> String {
let opts = CompileOptions {
emit_ir_only: true,
..Default::default()
};
String::from_utf8(compile_module(module, opts).expect("fixture must compile"))
.expect("LLVM IR is UTF-8")
}

/// `const f: (p0, …) => any = (p0, …) => p<last>; f(1, 2, …, argc)`.
fn closure_value_call_ir(file: &str, argc: usize) -> String {
let mut module = Module::new(file);
module.init.push(Stmt::Let {
id: 1,
name: "f".to_string(),
ty: function_type(argc),
mutable: false,
init: Some(Expr::Closure {
func_id: 7,
params: params(100, argc),
return_type: Type::Any,
body: vec![Stmt::Return(Some(Expr::LocalGet(100 + argc as u32 - 1)))],
captures: Vec::new(),
mutable_captures: Vec::new(),
captures_this: false,
captures_new_target: false,
enclosing_class: None,
is_arrow: true,
is_async: false,
is_generator: false,
is_strict: true,
}),
});
module.init.push(Stmt::Expr(Expr::Call {
callee: Box::new(Expr::LocalGet(1)),
args: (1..=argc).map(|i| Expr::Number(i as f64)).collect(),
type_args: Vec::new(),
byte_offset: 0,
}));
ir(&module)
}

#[test]
fn a_wide_closure_value_call_compiles_through_the_array_path() {
let ir = closure_value_call_ir("closure_call_arity_wide.ts", 18);
assert!(
ir.contains("call double @js_closure_call_array(i64 ") && ir.contains(", i64 18)"),
"an 18-argument closure-value call must dispatch through \
js_closure_call_array with argc 18:\n{ir}"
);
assert!(
!ir.contains("@js_closure_call18("),
"no fixed-arity entry point exists past 16:\n{ir}"
);
}

#[test]
fn a_narrow_closure_value_call_keeps_the_fixed_arity_fast_path() {
let ir = closure_value_call_ir("closure_call_arity_narrow.ts", 3);
assert!(
ir.contains("call double @js_closure_call3(i64 "),
"a 3-argument closure-value call must keep js_closure_call3:\n{ir}"
);
assert!(
!ir.contains("call double @js_closure_call_array("),
"a 3-argument call must not take the array path:\n{ir}"
);
}

#[test]
fn function_value_wrappers_take_every_declared_param() {
let mut module = Module::new("closure_call_arity_wrapper.ts");
module.functions = vec![Function {
id: 1,
name: "wide".to_string(),
type_params: Vec::new(),
params: params(10, 18),
return_type: Type::Any,
body: vec![Stmt::Return(Some(Expr::LocalGet(27)))],
is_async: false,
is_generator: false,
is_strict: true,
was_plain_async: false,
was_unrolled: false,
is_exported: false,
captures: Vec::new(),
decorators: Vec::new(),
}];
let ir = ir(&module);
let define = ir
.lines()
.find(|line| {
line.starts_with("define") && line.contains("@__perry_wrap_") && line.contains("wide(")
})
.unwrap_or_else(|| panic!("expected the value wrapper of `wide`:\n{ir}"));
assert_eq!(
define.matches("double %a").count(),
18,
"the wrapper must forward all 18 params, not a 16-param prefix: {define}"
);
}
39 changes: 9 additions & 30 deletions crates/perry-codegen/src/lower_call/console_promise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1640,43 +1640,22 @@ fn lower_closure_call_rooted<'a>(
lowered_args.push(group.reread(ctx, arg_base + i)?);
}

let result = if lowered_args.len() <= 16 {
let runtime_fn = if receiverless_one_arg {
"js_closure_call1_receiverless".to_string()
} else {
format!("js_closure_call{}", lowered_args.len())
};
let blk = ctx.block();
let mut call_args: Vec<(crate::types::LlvmType, &str)> = vec![(I64, &closure_handle)];
for v in &lowered_args {
call_args.push((DOUBLE, v.as_str()));
}
blk.call(DOUBLE, &runtime_fn, &call_args)
let result = if receiverless_one_arg {
ctx.block().call(
DOUBLE,
"js_closure_call1_receiverless",
&[(I64, &closure_handle), (DOUBLE, &lowered_args[0])],
)
} else {
// #3527: > 16 args — stack-allocate a `[N x double]` array (entry-block
// alloca, see #167), store each lowered arg, and dispatch through the
// variadic `js_closure_call_array(closure_i64, args_ptr, argc)`. This
// mirrors the `js_native_call_value` marshaling used elsewhere in
// lower_call. `args_ptr` is non-null here since argc > 16 > 0.
// #3527: > 16 args marshal into an entry-block `[N x double]` buffer and
// dispatch through the variadic `js_closure_call_array`.
//
// #7154: the stores happen below the unbox now. A stack buffer is not a
// GC root, so filling it above an allocating rebind would freeze
// pre-move addresses into it — the same staleness one indirection
// further out. The stores have no observable effect, so moving them
// below the throw-capable unbox changes nothing else.
let n = lowered_args.len();
let buf = ctx.func.alloca_entry_array(DOUBLE, n);
let blk = ctx.block();
for (i, v) in lowered_args.iter().enumerate() {
let slot = blk.gep(DOUBLE, &buf, &[(I64, &format!("{}", i))]);
blk.store(DOUBLE, v, &slot);
}
let argc = n.to_string();
blk.call(
DOUBLE,
"js_closure_call_array",
&[(I64, &closure_handle), (PTR, &buf), (I64, &argc)],
)
super::emit_closure_handle_call(ctx, &closure_handle, &lowered_args)
};

// #7211: re-read the saved implicit `this` from its slot. Mandatory, not
Expand Down
Loading
Loading