From 4b291d5d8a297c733877ab373b28dac7644a1abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 25 Jun 2026 08:50:15 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(runtime):=20#5437=20=E2=80=94=20unbound?= =?UTF-8?q?ed-arity=20vtable=20dispatch=20+=20streaming=20res.write(cb)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Next.js app-route blockers, both surfacing only at giant-bundle scale. 1. call_vtable_method capped dynamic dispatch at 64 args. A synthesized capture-stashing constructor (synthesize_class_captures appends one __perry_cap_* param per captured enclosing local) can have 130+ params — Next.js app-route-turbo's route-module class rJ has 134. In release builds the debug_assert was compiled out, so a 135-param ctor was transmuted to a 64-arg fn signature: every param past the 64th read register/stack garbage, a captured module-scope function (r_/rQ) arrived non-callable, and this.methods = r_(e) threw 'value is not a function' → route-module init aborted before self-registering exports → require returned undefined → 'Cannot find module route.js' → HTTP 500 on every app route. New crates/perry-runtime/src/abi_trampoline.rs implements an all-f64 arbitrary-arity call via an aarch64/x86-64 inline-asm trampoline (first 8 f64 args in FP registers, the rest spilled to a 16-byte-aligned stack area, per the platform C ABI — faithful because every Perry method/ctor param is f64). call_vtable_method now builds the positional arg list and dispatches through it; MAX_VTABLE_DISPATCH_ARITY=512. Unit tests cover 70-arg ordering and stack-spilled args. 2. js_node_http_res_write_with_cb ignored streaming mode. After res.flushHeaders() (or a prior streamed write) begins streaming, the callback-aware write dispatch arm — used by Next's pipeToNodeResponse WritableStream (res.write(chunk)) — still buffered every chunk into buffered_body, while res.end() took the stream-finalize path that only sends the final .end(chunk) arg and drops buffered_body. The API-route JSON body never reached the wire (HTTP 200, 0 bytes). Now routes through stream_write first, exactly like js_node_http_res_write. Result on the Next.js 16 standalone bundle: GET /api/hello 500 → 200 with the correct {"hello":"world","n":42} body (byte-identical to node); static /, /about, /counter remain byte-identical. --- crates/perry-ext-http-server/src/response.rs | 28 ++ crates/perry-runtime/src/abi_trampoline.rs | 384 ++++++++++++++++++ crates/perry-runtime/src/lib.rs | 1 + .../src/object/class_registry/dispatch.rs | 311 +++----------- 4 files changed, 462 insertions(+), 262 deletions(-) create mode 100644 crates/perry-runtime/src/abi_trampoline.rs diff --git a/crates/perry-ext-http-server/src/response.rs b/crates/perry-ext-http-server/src/response.rs index 2f68c57369..6a334cd8ab 100644 --- a/crates/perry-ext-http-server/src/response.rs +++ b/crates/perry-ext-http-server/src/response.rs @@ -1667,6 +1667,34 @@ pub extern "C" fn js_node_http_res_detach_socket(handle: i64, _socket: f64) { #[no_mangle] pub extern "C" fn js_node_http_res_write_with_cb(handle: i64, chunk: f64, callback: i64) -> i32 { let bytes = jsvalue_to_body_bytes(chunk); + // Honor streaming mode (after `res.flushHeaders()` / a prior streamed + // `res.write`) exactly like `js_node_http_res_write`: the chunk must go down + // the stream channel, NOT into `buffered_body`. Without this, a streamed + // response whose chunks arrive via the callback-aware dispatch arm + // (`res.write(chunk)` from Next's `pipeToNodeResponse` WritableStream) + // buffered every chunk while `.end()` took the stream-finalize path — which + // only sends the final `.end(chunk)` arg and drops `buffered_body` entirely, + // so the JSON API-route body never reached the wire (HTTP 200, 0 bytes). + // #5437 (Next.js app-route response pipe). + if let Some(b) = &bytes { + let ended = get_handle::(handle) + .map(|sr| sr.writable_ended) + .unwrap_or(true); + if !ended { + if let Some(below_hwm) = stream_write(handle, b) { + if callback != 0 { + // The streamed bytes are already on the wire; fire the + // write callback on the next pump tick (Node calls it once + // the chunk is flushed). Queueing it keeps call ordering and + // lets `.end()`'s callback drain run after it (#4904). + if let Some(sr) = get_handle_mut::(handle) { + sr.pending_write_callbacks.push(callback); + } + } + return below_hwm as i32; + } + } + } // #4909 — real backpressure boolean (mirrors `js_node_http_res_write_full` // on the static path): `false` past the 16 KiB high-water mark, so dynamic // `while (res.write(buf, cb))` producer loops terminate. diff --git a/crates/perry-runtime/src/abi_trampoline.rs b/crates/perry-runtime/src/abi_trampoline.rs new file mode 100644 index 0000000000..d3abe839c8 --- /dev/null +++ b/crates/perry-runtime/src/abi_trampoline.rs @@ -0,0 +1,384 @@ +//! Arbitrary-arity all-`f64` call trampoline. +//! +//! Perry-generated methods and constructors have the C signature +//! `double(double this, double arg0, …, double argN)`. The dynamic vtable +//! dispatch (`object::class_registry::dispatch::call_vtable_method`) must invoke +//! such a function for an arity only known at runtime — and a synthesized +//! capture-stashing constructor can have 130+ params (one per captured +//! enclosing local in a giant minified bundle, e.g. Next.js app-route-turbo's +//! route-module class `rJ`). Hand-writing a `match`-arm-per-arity dispatch caps +//! out (the pre-#5437 64-arm cap silently transmuted a 135-param ctor to a +//! 64-arg signature in release builds, so every param past the 64th read +//! register/stack garbage — a captured function arrived non-callable and the +//! ctor threw "value is not a function"). +//! +//! Because EVERY argument is an `f64`, the platform C ABI is fully determined: +//! the first 8 floating-point args go in FP argument registers and the rest are +//! spilled to a 16-byte-aligned stack area. This module implements that call +//! directly with inline assembly for the two hosted architectures (aarch64 + +//! x86-64); other targets fall back to a fixed-arity dispatch good to 16 args +//! (no Perry target other than the two asm ones exercises high-arity dynamic +//! ctor dispatch today). + +/// Call `func_ptr` (a `extern "C" double(double, …)` with `args.len()` f64 +/// params) passing every element of `args` as an f64 argument. Returns the f64 +/// result. +/// +/// # Safety +/// `func_ptr` must be a valid code pointer to a function whose C signature is +/// `double(double × args.len())`. All Perry method/ctor params are `f64`. +#[inline] +pub(crate) unsafe fn call_all_f64(func_ptr: usize, args: &[f64]) -> f64 { + #[cfg(target_arch = "aarch64")] + { + call_all_f64_aarch64(func_ptr, args) + } + #[cfg(target_arch = "x86_64")] + { + call_all_f64_x86_64(func_ptr, args) + } + #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] + { + call_all_f64_fallback(func_ptr, args) + } +} + +/// AAPCS64: the first 8 f64 args go in v0–v7; args 9+ are spilled to the stack +/// in order, each occupying 8 bytes, with the stack 16-byte aligned at the call. +#[cfg(target_arch = "aarch64")] +#[inline(never)] +unsafe fn call_all_f64_aarch64(func_ptr: usize, args: &[f64]) -> f64 { + use core::arch::asm; + + let n = args.len(); + // Register args (up to 8); pad missing with 0.0 (callee won't read them). + let mut reg = [0.0f64; 8]; + for (i, slot) in reg.iter_mut().enumerate() { + if i < n { + *slot = args[i]; + } + } + + // Stack-spilled args: args[8..]. Bytes = stacked_count * 8, rounded up to a + // 16-byte multiple so `sp` stays 16-aligned across the `blr`. + let stacked = if n > 8 { &args[8..] } else { &[][..] }; + let stacked_count = stacked.len(); + let raw_bytes = stacked_count * 8; + let stack_bytes = (raw_bytes + 15) & !15; + + let ret: f64; + asm!( + // Stash the current sp in a CALLEE-SAVED register (x20, declared as a + // clobber below so the compiler saves/restores it around this asm). The + // callee preserves callee-saved registers, so x20 survives the `blr` — + // restoring sp from a caller-saved copy or by re-adding a clobbered + // `stack_bytes` would corrupt the stack. + "mov x20, sp", + // Reserve aligned stack space for the spilled args. + "sub sp, sp, {stack_bytes}", + // Copy spilled args from the source pointer into [sp + i*8]. + "mov {i}, xzr", + "cbz {cnt}, 3f", + "2:", + "ldr {tmp}, [{src}, {i}, lsl #3]", + "str {tmp}, [sp, {i}, lsl #3]", + "add {i}, {i}, #1", + "cmp {i}, {cnt}", + "b.lo 2b", + "3:", + "blr {func}", + // Restore sp from the callee-saved copy. + "mov sp, x20", + func = in(reg) func_ptr, + src = in(reg) stacked.as_ptr(), + cnt = in(reg) stacked_count, + stack_bytes = in(reg) stack_bytes, + i = out(reg) _, + tmp = out(reg) _, + out("x20") _, + // FP argument registers v0–v7. + inout("d0") reg[0] => ret, + inout("d1") reg[1] => _, + inout("d2") reg[2] => _, + inout("d3") reg[3] => _, + inout("d4") reg[4] => _, + inout("d5") reg[5] => _, + inout("d6") reg[6] => _, + inout("d7") reg[7] => _, + // Caller-saved registers the callee may clobber (AAPCS64). x0–x17 and + // x30(lr) are call-clobbered GPRs; v8–v15 lower 64 bits are + // callee-saved (preserved), v16–v31 are caller-saved. + lateout("x0") _, lateout("x1") _, lateout("x2") _, lateout("x3") _, + lateout("x4") _, lateout("x5") _, lateout("x6") _, lateout("x7") _, + lateout("x8") _, lateout("x9") _, lateout("x10") _, lateout("x11") _, + lateout("x12") _, lateout("x13") _, lateout("x14") _, lateout("x15") _, + lateout("x16") _, lateout("x17") _, lateout("x30") _, + lateout("v16") _, lateout("v17") _, lateout("v18") _, lateout("v19") _, + lateout("v20") _, lateout("v21") _, lateout("v22") _, lateout("v23") _, + lateout("v24") _, lateout("v25") _, lateout("v26") _, lateout("v27") _, + lateout("v28") _, lateout("v29") _, lateout("v30") _, lateout("v31") _, + ); + ret +} + +/// SysV x86-64: the first 8 f64 args go in xmm0–xmm7; args 9+ are spilled to the +/// stack (each 8 bytes), with the stack 16-byte aligned at the `call`. `al` must +/// hold the number of vector registers used for a (possibly) variadic callee; +/// Perry callees are non-variadic, but setting `al` is harmless and matches the +/// ABI requirement for safety. +#[cfg(target_arch = "x86_64")] +#[inline(never)] +unsafe fn call_all_f64_x86_64(func_ptr: usize, args: &[f64]) -> f64 { + use core::arch::asm; + + let n = args.len(); + let mut reg = [0.0f64; 8]; + for (i, slot) in reg.iter_mut().enumerate() { + if i < n { + *slot = args[i]; + } + } + + let stacked = if n > 8 { &args[8..] } else { &[][..] }; + let stacked_count = stacked.len(); + // Stack must be 16-aligned at the call instruction. The `call` pushes an + // 8-byte return address, so before the `call` we need `sp % 16 == 0`. We + // reserve a 16-byte multiple for the spilled args; if `stacked_count` is + // odd, the natural 8-byte total would misalign, so round up. + let raw_bytes = stacked_count * 8; + let stack_bytes = (raw_bytes + 15) & !15; + + let ret: f64; + asm!( + // Stash the pre-adjust rsp in rbx (CALLEE-SAVED, declared as a clobber + // below so the compiler saves/restores it). The callee preserves rbx, so + // the sp restore survives the callee clobbering every caller-saved + // register (including any holding `stack_bytes`). + "mov rbx, rsp", + // Reserve space for spilled args, then force rsp 16-aligned so that the + // `call` (which pushes the 8-byte return address) leaves the callee + // entry with rsp ≡ 8 (mod 16), per SysV. `stack_bytes` is a 16-multiple, + // so aligning rsp down by clearing the low 4 bits keeps room for all + // spilled slots (they are written relative to the post-align rsp). + "sub rsp, {stack_bytes}", + "and rsp, -16", + "xor {i:e}, {i:e}", + "test {cnt}, {cnt}", + "jz 3f", + "2:", + "mov {tmp}, qword ptr [{src} + {i}*8]", + "mov qword ptr [rsp + {i}*8], {tmp}", + "inc {i}", + "cmp {i}, {cnt}", + "jb 2b", + "3:", + "call {func}", + "mov rsp, rbx", + func = in(reg) func_ptr, + src = in(reg) stacked.as_ptr(), + cnt = in(reg) stacked_count, + stack_bytes = in(reg) stack_bytes, + i = out(reg) _, + tmp = out(reg) _, + out("rbx") _, + inout("xmm0") reg[0] => ret, + inout("xmm1") reg[1] => _, + inout("xmm2") reg[2] => _, + inout("xmm3") reg[3] => _, + inout("xmm4") reg[4] => _, + inout("xmm5") reg[5] => _, + inout("xmm6") reg[6] => _, + inout("xmm7") reg[7] => _, + // Caller-saved GPRs the callee may clobber (SysV). `al` (in rax) is set + // to the FP-register count for variadic safety. xmm8–xmm15 are + // caller-saved on SysV too. + inout("rax") 8u64 => _, lateout("rcx") _, lateout("rdx") _, lateout("rsi") _, + lateout("rdi") _, lateout("r8") _, lateout("r9") _, lateout("r10") _, + lateout("r11") _, + lateout("xmm8") _, lateout("xmm9") _, lateout("xmm10") _, lateout("xmm11") _, + lateout("xmm12") _, lateout("xmm13") _, lateout("xmm14") _, lateout("xmm15") _, + ); + ret +} + +/// Portable fallback for non-asm targets: fixed-arity dispatch up to 16 f64 +/// args. No current Perry host other than aarch64/x86-64 exercises high-arity +/// dynamic ctor dispatch, so this bound is sufficient there. +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +#[inline(never)] +unsafe fn call_all_f64_fallback(func_ptr: usize, args: &[f64]) -> f64 { + #[inline(always)] + fn a(args: &[f64], i: usize) -> f64 { + args.get(i) + .copied() + .unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)) + } + macro_rules! arm { + ($($i:expr),*) => {{ + let f: extern "C" fn($(replace_expr!($i f64)),*) -> f64 = + std::mem::transmute(func_ptr); + f($(a(args, $i)),*) + }}; + } + macro_rules! replace_expr { + ($_t:expr, $sub:ty) => { + $sub + }; + } + // args already includes `this` as element 0. + match args.len() { + 0 => 0.0, + 1 => arm!(0), + 2 => arm!(0, 1), + 3 => arm!(0, 1, 2), + 4 => arm!(0, 1, 2, 3), + 5 => arm!(0, 1, 2, 3, 4), + 6 => arm!(0, 1, 2, 3, 4, 5), + 7 => arm!(0, 1, 2, 3, 4, 5, 6), + 8 => arm!(0, 1, 2, 3, 4, 5, 6, 7), + 9 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8), + 10 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), + 11 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10), + 12 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11), + 13 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), + 14 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13), + 15 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14), + _ => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A 70-param all-f64 callee: returns arg0*1 + arg1*2 + ... weighted sum so + // a misplaced/garbage arg is detectable, plus marker on the last few. + extern "C" fn sum70( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + a12: f64, + a13: f64, + a14: f64, + a15: f64, + a16: f64, + a17: f64, + a18: f64, + a19: f64, + a20: f64, + a21: f64, + a22: f64, + a23: f64, + a24: f64, + a25: f64, + a26: f64, + a27: f64, + a28: f64, + a29: f64, + a30: f64, + a31: f64, + a32: f64, + a33: f64, + a34: f64, + a35: f64, + a36: f64, + a37: f64, + a38: f64, + a39: f64, + a40: f64, + a41: f64, + a42: f64, + a43: f64, + a44: f64, + a45: f64, + a46: f64, + a47: f64, + a48: f64, + a49: f64, + a50: f64, + a51: f64, + a52: f64, + a53: f64, + a54: f64, + a55: f64, + a56: f64, + a57: f64, + a58: f64, + a59: f64, + a60: f64, + a61: f64, + a62: f64, + a63: f64, + a64: f64, + a65: f64, + a66: f64, + a67: f64, + a68: f64, + a69: f64, + ) -> f64 { + let xs = [ + a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, + a19, a20, a21, a22, a23, a24, a25, a26, a27, a28, a29, a30, a31, a32, a33, a34, a35, + a36, a37, a38, a39, a40, a41, a42, a43, a44, a45, a46, a47, a48, a49, a50, a51, a52, + a53, a54, a55, a56, a57, a58, a59, a60, a61, a62, a63, a64, a65, a66, a67, a68, a69, + ]; + let mut acc = 0.0; + for (i, x) in xs.iter().enumerate() { + acc += x * (i as f64 + 1.0); + } + acc + } + + #[test] + fn trampoline_passes_70_args_in_order() { + // args = [this=100, then 69 values 1..=69]. call_all_f64 takes the full + // arg list including `this` as element 0 → 70 total → sum70. + let mut args = Vec::with_capacity(70); + args.push(100.0); // a0 (this) + for i in 1..70 { + args.push(i as f64); + } + let got = unsafe { call_all_f64(sum70 as usize, &args) }; + // expected = sum(args[i]*(i+1)) + let expected: f64 = args + .iter() + .enumerate() + .map(|(i, x)| x * (i as f64 + 1.0)) + .sum(); + assert_eq!(got, expected, "trampoline mis-ordered args"); + } + + extern "C" fn pick( + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + g: f64, + h: f64, + i: f64, + j: f64, + ) -> f64 { + // beyond-register-window pick: returns the 9th and 10th (stack) args + // combined so a stack-spill bug is caught. + let _ = (a, b, c, d, e, f, g, h); + i * 1000.0 + j + } + + #[test] + fn trampoline_stack_spill_args_9_and_10() { + let args = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 42.0, 7.0]; + let got = unsafe { call_all_f64(pick as usize, &args) }; + assert_eq!(got, 42.0 * 1000.0 + 7.0); + } +} diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 29d2e98cb1..e1e203dab0 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -23,6 +23,7 @@ #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; +pub mod abi_trampoline; pub mod app_group; pub mod arena; pub mod array; diff --git a/crates/perry-runtime/src/object/class_registry/dispatch.rs b/crates/perry-runtime/src/object/class_registry/dispatch.rs index e6574c0d17..cc3149a950 100644 --- a/crates/perry-runtime/src/object/class_registry/dispatch.rs +++ b/crates/perry-runtime/src/object/class_registry/dispatch.rs @@ -134,6 +134,41 @@ pub(crate) unsafe fn vtable_ic_insert( }); } +/// Maximum positional arity `call_vtable_method` can invoke directly. The +/// dispatch builds a fixed-arity `extern "C"` fn signature for each arity up to +/// this cap (see `vtable_call_dispatch!`). Synthesized capture-stashing +/// constructors (`synthesize_class_captures`) append one `__perry_cap_*` param +/// per captured outer local; a giant minified bundle module (Next.js +/// app-route-turbo's `rJ` route-module class) can capture 130+ IIFE-scope +/// locals, so the cap must comfortably exceed that. Before #5437 the dispatch +/// topped out at 64 and silently transmuted a 135-param ctor to a 64-arg +/// signature in release builds (the `debug_assert!` was compiled out) — every +/// param past the 64th received register/stack garbage, so a captured function +/// (`r_`/`rQ`) arrived as a non-callable and `this.methods = r_(e)` threw +/// "value is not a function", aborting Next route-module init → HTTP 500. +pub(crate) const MAX_VTABLE_DISPATCH_ARITY: usize = 512; + +/// Call a `double(double this, double, …, double)` function pointer with `this` +/// plus `nargs` f64 arguments read from `args` (missing slots → `undefined`), +/// for an arbitrary `nargs` (bounded by [`MAX_VTABLE_DISPATCH_ARITY`]). +/// +/// The dynamic vtable path can't form an arbitrary-arity Rust `fn` type at +/// runtime, and hand-writing a `match` arm per arity caps out (the pre-#5437 +/// 64-arm cap silently mis-called 130+-param synthesized capture ctors). This +/// uses a tiny architecture-specific trampoline: f64 args go in the FP argument +/// registers (first 8) with the remainder spilled to the stack per the platform +/// C ABI, exactly as a native call of that arity would. All Perry-generated +/// method/ctor params are `f64`, so an all-f64 calling convention is faithful. +#[inline] +unsafe fn call_fn_with_f64_args(func_ptr: usize, this_f64: f64, args: &[f64]) -> f64 { + debug_assert!(args.len() <= MAX_VTABLE_DISPATCH_ARITY); + // Build the full argument vector: `this` followed by the positional args. + let mut all: Vec = Vec::with_capacity(args.len() + 1); + all.push(this_f64); + all.extend_from_slice(args); + crate::abi_trampoline::call_all_f64(func_ptr, &all) +} + /// Call a vtable method with the correct arity. /// All method params are f64, `this` is i64. pub(crate) unsafe fn call_vtable_method( @@ -232,269 +267,21 @@ pub(crate) unsafe fn call_vtable_method( (args_ptr, args_len) }; - match param_count { - 0 => { - let f: extern "C" fn(f64) -> f64 = std::mem::transmute(func_ptr); - f(this_f64) - } - 1 => { - let f: extern "C" fn(f64, f64) -> f64 = std::mem::transmute(func_ptr); - f(this_f64, arg_or_undefined(call_args_ptr, call_args_len, 0)) - } - 2 => { - let f: extern "C" fn(f64, f64, f64) -> f64 = std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - ) - } - 3 => { - let f: extern "C" fn(f64, f64, f64, f64) -> f64 = std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - ) - } - 4 => { - let f: extern "C" fn(f64, f64, f64, f64, f64) -> f64 = std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - arg_or_undefined(call_args_ptr, call_args_len, 3), - ) - } - 5 => { - let f: extern "C" fn(f64, f64, f64, f64, f64, f64) -> f64 = - std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - arg_or_undefined(call_args_ptr, call_args_len, 3), - arg_or_undefined(call_args_ptr, call_args_len, 4), - ) - } - 6 => { - let f: extern "C" fn(f64, f64, f64, f64, f64, f64, f64) -> f64 = - std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - arg_or_undefined(call_args_ptr, call_args_len, 3), - arg_or_undefined(call_args_ptr, call_args_len, 4), - arg_or_undefined(call_args_ptr, call_args_len, 5), - ) - } - 7 => { - let f: extern "C" fn(f64, f64, f64, f64, f64, f64, f64, f64) -> f64 = - std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - arg_or_undefined(call_args_ptr, call_args_len, 3), - arg_or_undefined(call_args_ptr, call_args_len, 4), - arg_or_undefined(call_args_ptr, call_args_len, 5), - arg_or_undefined(call_args_ptr, call_args_len, 6), - ) - } - 8 => { - let f: extern "C" fn(f64, f64, f64, f64, f64, f64, f64, f64, f64) -> f64 = - std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - arg_or_undefined(call_args_ptr, call_args_len, 3), - arg_or_undefined(call_args_ptr, call_args_len, 4), - arg_or_undefined(call_args_ptr, call_args_len, 5), - arg_or_undefined(call_args_ptr, call_args_len, 6), - arg_or_undefined(call_args_ptr, call_args_len, 7), - ) - } - 9 => { - let f: extern "C" fn(f64, f64, f64, f64, f64, f64, f64, f64, f64, f64) -> f64 = - std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - arg_or_undefined(call_args_ptr, call_args_len, 3), - arg_or_undefined(call_args_ptr, call_args_len, 4), - arg_or_undefined(call_args_ptr, call_args_len, 5), - arg_or_undefined(call_args_ptr, call_args_len, 6), - arg_or_undefined(call_args_ptr, call_args_len, 7), - arg_or_undefined(call_args_ptr, call_args_len, 8), - ) - } - // Arities above the explicit arms: the generated method/ctor signature is - // `double(double this, double×param_count)`. Rust can't form a - // param_count-arity fn pointer dynamically, so transmute to a generous - // fixed arity (64) and pass `param_count` real args plus `undefined` - // padding (`arg_or_undefined` yields undefined past `call_args_len`). - // Passing MORE args than the callee declares is safe on every target — - // the arg area is caller-allocated and caller-cleaned, and the callee - // reads only its declared params. This is the runtime-dispatch counterpart - // to the codegen direct call, and matters for ctors/methods that take many - // params — notably a class capturing dozens of module-level `require`s - // (`__perry_cap_*` params), the wall-45 `Derived extends _mod.default` - // shape, where the pre-fix 10-arg cap silently dropped captures 10+. - // (The prior `_` arm called every >9-arity function as if it had 10 - // params.) `debug_assert` flags the rare class that would still exceed - // the bound so it surfaces in tests rather than as silent corruption. - _ => { - debug_assert!( - param_count as usize <= 64, - "call_vtable_method: param_count {} exceeds fixed dispatch arity 64", - param_count - ); - let f: extern "C" fn( - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - f64, - ) -> f64 = std::mem::transmute(func_ptr); - f( - this_f64, - arg_or_undefined(call_args_ptr, call_args_len, 0), - arg_or_undefined(call_args_ptr, call_args_len, 1), - arg_or_undefined(call_args_ptr, call_args_len, 2), - arg_or_undefined(call_args_ptr, call_args_len, 3), - arg_or_undefined(call_args_ptr, call_args_len, 4), - arg_or_undefined(call_args_ptr, call_args_len, 5), - arg_or_undefined(call_args_ptr, call_args_len, 6), - arg_or_undefined(call_args_ptr, call_args_len, 7), - arg_or_undefined(call_args_ptr, call_args_len, 8), - arg_or_undefined(call_args_ptr, call_args_len, 9), - arg_or_undefined(call_args_ptr, call_args_len, 10), - arg_or_undefined(call_args_ptr, call_args_len, 11), - arg_or_undefined(call_args_ptr, call_args_len, 12), - arg_or_undefined(call_args_ptr, call_args_len, 13), - arg_or_undefined(call_args_ptr, call_args_len, 14), - arg_or_undefined(call_args_ptr, call_args_len, 15), - arg_or_undefined(call_args_ptr, call_args_len, 16), - arg_or_undefined(call_args_ptr, call_args_len, 17), - arg_or_undefined(call_args_ptr, call_args_len, 18), - arg_or_undefined(call_args_ptr, call_args_len, 19), - arg_or_undefined(call_args_ptr, call_args_len, 20), - arg_or_undefined(call_args_ptr, call_args_len, 21), - arg_or_undefined(call_args_ptr, call_args_len, 22), - arg_or_undefined(call_args_ptr, call_args_len, 23), - arg_or_undefined(call_args_ptr, call_args_len, 24), - arg_or_undefined(call_args_ptr, call_args_len, 25), - arg_or_undefined(call_args_ptr, call_args_len, 26), - arg_or_undefined(call_args_ptr, call_args_len, 27), - arg_or_undefined(call_args_ptr, call_args_len, 28), - arg_or_undefined(call_args_ptr, call_args_len, 29), - arg_or_undefined(call_args_ptr, call_args_len, 30), - arg_or_undefined(call_args_ptr, call_args_len, 31), - arg_or_undefined(call_args_ptr, call_args_len, 32), - arg_or_undefined(call_args_ptr, call_args_len, 33), - arg_or_undefined(call_args_ptr, call_args_len, 34), - arg_or_undefined(call_args_ptr, call_args_len, 35), - arg_or_undefined(call_args_ptr, call_args_len, 36), - arg_or_undefined(call_args_ptr, call_args_len, 37), - arg_or_undefined(call_args_ptr, call_args_len, 38), - arg_or_undefined(call_args_ptr, call_args_len, 39), - arg_or_undefined(call_args_ptr, call_args_len, 40), - arg_or_undefined(call_args_ptr, call_args_len, 41), - arg_or_undefined(call_args_ptr, call_args_len, 42), - arg_or_undefined(call_args_ptr, call_args_len, 43), - arg_or_undefined(call_args_ptr, call_args_len, 44), - arg_or_undefined(call_args_ptr, call_args_len, 45), - arg_or_undefined(call_args_ptr, call_args_len, 46), - arg_or_undefined(call_args_ptr, call_args_len, 47), - arg_or_undefined(call_args_ptr, call_args_len, 48), - arg_or_undefined(call_args_ptr, call_args_len, 49), - arg_or_undefined(call_args_ptr, call_args_len, 50), - arg_or_undefined(call_args_ptr, call_args_len, 51), - arg_or_undefined(call_args_ptr, call_args_len, 52), - arg_or_undefined(call_args_ptr, call_args_len, 53), - arg_or_undefined(call_args_ptr, call_args_len, 54), - arg_or_undefined(call_args_ptr, call_args_len, 55), - arg_or_undefined(call_args_ptr, call_args_len, 56), - arg_or_undefined(call_args_ptr, call_args_len, 57), - arg_or_undefined(call_args_ptr, call_args_len, 58), - arg_or_undefined(call_args_ptr, call_args_len, 59), - arg_or_undefined(call_args_ptr, call_args_len, 60), - arg_or_undefined(call_args_ptr, call_args_len, 61), - arg_or_undefined(call_args_ptr, call_args_len, 62), - arg_or_undefined(call_args_ptr, call_args_len, 63), - ) - } + // All Perry method/ctor params are `f64`. Build the positional arg list + // (missing trailing args → `undefined` per spec) and invoke through the + // arbitrary-arity all-f64 trampoline. A fixed `match`-arm-per-arity dispatch + // previously capped at 64 and silently mis-called 130+-param synthesized + // capture constructors (#5437). + debug_assert!( + param_count as usize <= MAX_VTABLE_DISPATCH_ARITY, + "call_vtable_method: param_count {} exceeds MAX_VTABLE_DISPATCH_ARITY", + param_count + ); + let mut positional: Vec = Vec::with_capacity(param_count as usize); + for i in 0..(param_count as usize) { + positional.push(arg_or_undefined(call_args_ptr, call_args_len, i)); } + call_fn_with_f64_args(func_ptr, this_f64, &positional) } /// Walk the class parent chain looking for a recorded fetch-builtin parent From 34eced8fa981d25597834f5f1533d2b4dbc19720 Mon Sep 17 00:00:00 2001 From: Ralph Date: Thu, 25 Jun 2026 00:20:46 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(runtime):=20#5437=20=E2=80=94=20use=20r?= =?UTF-8?q?12=20(not=20rbx)=20for=20rsp=20stash=20in=20x86-64=20ABI=20tram?= =?UTF-8?q?poline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `abi_trampoline.rs`'s `call_all_f64_x86_64` stashed the pre-adjust rsp in rbx across the `call`, declaring `out("rbx") _`. LLVM reserves rbx internally and rejects it as an explicit inline-asm operand, so the x86-64 build failed: error: cannot use register `bx`: rbx is used internally by LLVM and cannot be used as an operand for inline asm --> abi_trampoline.rs:183 This only surfaced on x86-64 CI (the contributor builds on aarch64, which takes the separate aarch64 asm path). Switch the stash register to r12 — also callee-saved (so it survives the callee clobbering caller-saved regs) but, unlike rbx, usable as an explicit inline-asm operand. Verified by cross-compiling perry-runtime for x86_64-apple-darwin (was: 1 error; now: clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-runtime/src/abi_trampoline.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/perry-runtime/src/abi_trampoline.rs b/crates/perry-runtime/src/abi_trampoline.rs index d3abe839c8..6c24c2f286 100644 --- a/crates/perry-runtime/src/abi_trampoline.rs +++ b/crates/perry-runtime/src/abi_trampoline.rs @@ -150,11 +150,13 @@ unsafe fn call_all_f64_x86_64(func_ptr: usize, args: &[f64]) -> f64 { let ret: f64; asm!( - // Stash the pre-adjust rsp in rbx (CALLEE-SAVED, declared as a clobber - // below so the compiler saves/restores it). The callee preserves rbx, so + // Stash the pre-adjust rsp in r12 (CALLEE-SAVED, declared as a clobber + // below so the compiler saves/restores it). The callee preserves r12, so // the sp restore survives the callee clobbering every caller-saved - // register (including any holding `stack_bytes`). - "mov rbx, rsp", + // register (including any holding `stack_bytes`). We use r12 rather than + // rbx because LLVM reserves rbx internally and rejects it as an explicit + // inline-asm operand; r12 is an equivalent callee-saved scratch. + "mov r12, rsp", // Reserve space for spilled args, then force rsp 16-aligned so that the // `call` (which pushes the 8-byte return address) leaves the callee // entry with rsp ≡ 8 (mod 16), per SysV. `stack_bytes` is a 16-multiple, @@ -173,14 +175,14 @@ unsafe fn call_all_f64_x86_64(func_ptr: usize, args: &[f64]) -> f64 { "jb 2b", "3:", "call {func}", - "mov rsp, rbx", + "mov rsp, r12", func = in(reg) func_ptr, src = in(reg) stacked.as_ptr(), cnt = in(reg) stacked_count, stack_bytes = in(reg) stack_bytes, i = out(reg) _, tmp = out(reg) _, - out("rbx") _, + out("r12") _, inout("xmm0") reg[0] => ret, inout("xmm1") reg[1] => _, inout("xmm2") reg[2] => _, From 8cfd8da6eec924dec1d2cc246f17863632d081a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 25 Jun 2026 10:41:58 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(runtime):=20#5437=20=E2=80=94=20CodeRab?= =?UTF-8?q?bit=20rework:=20Win64=20cfg=20gate,=20fail-closed=20fallback,?= =?UTF-8?q?=20release=20arity=20guard,=20stream-write=20cb=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - abi_trampoline: gate SysV x86-64 asm behind not(target_os=windows); Win64 (xmm0..3 + 32B shadow space) falls through to the portable fallback. - abi_trampoline: portable fallback FAILS CLOSED for arity >16 (panic) instead of transmuting to a 16-arg signature (the #5437 silent-miscompile class); add an explicit 16-arg arm; gate the 70-arg test to the asm targets. - dispatch: replace the debug_assert-only arity check in call_vtable_method with a real assert! (all builds) before building the positional Vec. - http-server response: add stream_write_with_cb that enqueues the write callback BEFORE the data frame is published (tx.send), so a receiver draining immediately can't run it out of order vs later writes/.end(). (rbx->r12 x86-64 operand fix was already committed in 34eced8fa.) --- crates/perry-ext-http-server/src/response.rs | 40 ++++++++++++----- crates/perry-runtime/src/abi_trampoline.rs | 45 +++++++++++++++---- .../src/object/class_registry/dispatch.rs | 16 +++++-- 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/crates/perry-ext-http-server/src/response.rs b/crates/perry-ext-http-server/src/response.rs index 6a334cd8ab..fa34d159e7 100644 --- a/crates/perry-ext-http-server/src/response.rs +++ b/crates/perry-ext-http-server/src/response.rs @@ -1076,14 +1076,34 @@ fn apply_headers_flat_array(sr: &mut ServerResponse, json: &str) { /// (`begin_streaming` succeeded now or earlier), `None` when it isn't — /// the caller falls back to the legacy buffered path. fn stream_write(handle: i64, bytes: &[u8]) -> Option { + stream_write_with_cb(handle, bytes, 0) +} + +/// `stream_write`, but also enqueues `callback` (if non-zero) into +/// `pending_write_callbacks` BEFORE the data frame is published on the channel. +/// +/// Ordering matters: the stream-frame send makes the bytes visible to the +/// reader task, which may drain and run pending write callbacks immediately. If +/// we pushed the callback AFTER `tx.send(...)` (as the call site used to), a +/// receiver that drains right away could run this write's callback out of order +/// — after later writes' callbacks or `.end()`. Registering it first keeps the +/// callback ordered relative to the frame it belongs to and to later writes. +fn stream_write_with_cb(handle: i64, bytes: &[u8], callback: i64) -> Option { if !begin_streaming(handle) { return None; } let sr = get_handle_mut::(handle)?; - let tx = sr.stream_tx.as_ref()?; - let in_flight = sr.stream_in_flight.as_ref()?; + // Clone the channel handles so the immutable borrow of `sr` ends before we + // mutate `pending_write_callbacks` / `needs_drain` (the sender + Arc are + // cheap to clone). The `fetch_add` reserves this chunk's byte count. + let tx = sr.stream_tx.as_ref()?.clone(); + let in_flight = sr.stream_in_flight.as_ref()?.clone(); let queued = in_flight.fetch_add(bytes.len(), std::sync::atomic::Ordering::AcqRel) + bytes.len(); + // Register the write callback BEFORE publishing the frame (see doc comment). + if callback != 0 { + sr.pending_write_callbacks.push(callback); + } let _ = tx.send(StreamFrame::Data(Bytes::copy_from_slice(bytes))); let below_hwm = queued <= DEFAULT_HIGH_WATER_MARK; if !below_hwm { @@ -1681,16 +1701,12 @@ pub extern "C" fn js_node_http_res_write_with_cb(handle: i64, chunk: f64, callba .map(|sr| sr.writable_ended) .unwrap_or(true); if !ended { - if let Some(below_hwm) = stream_write(handle, b) { - if callback != 0 { - // The streamed bytes are already on the wire; fire the - // write callback on the next pump tick (Node calls it once - // the chunk is flushed). Queueing it keeps call ordering and - // lets `.end()`'s callback drain run after it (#4904). - if let Some(sr) = get_handle_mut::(handle) { - sr.pending_write_callbacks.push(callback); - } - } + // Register the write callback BEFORE the data frame is published so + // a receiver that drains immediately can't run it out of order + // relative to later writes / `.end()` (Node fires it once the chunk + // is flushed; queued, it drains in order, #4904). `stream_write_with_cb` + // enqueues the callback ahead of the `tx.send`. + if let Some(below_hwm) = stream_write_with_cb(handle, b, callback) { return below_hwm as i32; } } diff --git a/crates/perry-runtime/src/abi_trampoline.rs b/crates/perry-runtime/src/abi_trampoline.rs index 6c24c2f286..6a811c7fbf 100644 --- a/crates/perry-runtime/src/abi_trampoline.rs +++ b/crates/perry-runtime/src/abi_trampoline.rs @@ -33,11 +33,18 @@ pub(crate) unsafe fn call_all_f64(func_ptr: usize, args: &[f64]) -> f64 { { call_all_f64_aarch64(func_ptr, args) } - #[cfg(target_arch = "x86_64")] + // NOTE: gated to NON-Windows x86-64. The asm below is the SysV ABI (FP args + // in xmm0..xmm7, no shadow space). The Windows x64 ABI passes FP args in + // xmm0..xmm3 and requires a 32-byte shadow space, so the SysV asm would + // mis-pass 5+ args. Win64 falls through to the portable fallback instead. + #[cfg(all(target_arch = "x86_64", not(target_os = "windows")))] { call_all_f64_x86_64(func_ptr, args) } - #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] + #[cfg(not(any( + target_arch = "aarch64", + all(target_arch = "x86_64", not(target_os = "windows")) + )))] { call_all_f64_fallback(func_ptr, args) } @@ -126,7 +133,7 @@ unsafe fn call_all_f64_aarch64(func_ptr: usize, args: &[f64]) -> f64 { /// hold the number of vector registers used for a (possibly) variadic callee; /// Perry callees are non-variadic, but setting `al` is harmless and matches the /// ABI requirement for safety. -#[cfg(target_arch = "x86_64")] +#[cfg(all(target_arch = "x86_64", not(target_os = "windows")))] #[inline(never)] unsafe fn call_all_f64_x86_64(func_ptr: usize, args: &[f64]) -> f64 { use core::arch::asm; @@ -203,10 +210,17 @@ unsafe fn call_all_f64_x86_64(func_ptr: usize, args: &[f64]) -> f64 { ret } -/// Portable fallback for non-asm targets: fixed-arity dispatch up to 16 f64 -/// args. No current Perry host other than aarch64/x86-64 exercises high-arity -/// dynamic ctor dispatch, so this bound is sufficient there. -#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +/// Portable fallback for non-asm targets (incl. Windows x64, whose ABI differs +/// from the SysV asm above): fixed-arity dispatch up to 16 f64 args. No current +/// Perry host other than SysV aarch64/x86-64 exercises high-arity dynamic ctor +/// dispatch, so this bound is sufficient there. Arities > 16 FAIL CLOSED: a +/// fixed 16-arg `transmute` would mis-call the fn pointer with the wrong +/// signature (reading register/stack garbage for the missing params), the exact +/// silent-miscompile class that motivated #5437 — so we panic instead. +#[cfg(not(any( + target_arch = "aarch64", + all(target_arch = "x86_64", not(target_os = "windows")) +)))] #[inline(never)] unsafe fn call_all_f64_fallback(func_ptr: usize, args: &[f64]) -> f64 { #[inline(always)] @@ -245,7 +259,15 @@ unsafe fn call_all_f64_fallback(func_ptr: usize, args: &[f64]) -> f64 { 13 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12), 14 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13), 15 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14), - _ => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15), + 16 => arm!(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15), + // FAIL CLOSED: do NOT transmute a >16-arg call to a 16-arg signature — + // the extra params would read register/stack garbage (#5437). This + // target has no asm trampoline; high-arity dynamic dispatch is + // unsupported here. + n => panic!( + "abi_trampoline: unsupported arity {n} on this target \ + (no asm trampoline; portable fallback caps at 16 f64 args)" + ), } } @@ -340,6 +362,13 @@ mod tests { acc } + // High-arity (>16) dynamic dispatch only works on the asm targets; the + // portable fallback fails closed (panics) above 16 args, so this test is + // gated to the SysV asm targets. + #[cfg(any( + target_arch = "aarch64", + all(target_arch = "x86_64", not(target_os = "windows")) + ))] #[test] fn trampoline_passes_70_args_in_order() { // args = [this=100, then 69 values 1..=69]. call_all_f64 takes the full diff --git a/crates/perry-runtime/src/object/class_registry/dispatch.rs b/crates/perry-runtime/src/object/class_registry/dispatch.rs index cc3149a950..19d8be3c2f 100644 --- a/crates/perry-runtime/src/object/class_registry/dispatch.rs +++ b/crates/perry-runtime/src/object/class_registry/dispatch.rs @@ -272,10 +272,18 @@ pub(crate) unsafe fn call_vtable_method( // arbitrary-arity all-f64 trampoline. A fixed `match`-arm-per-arity dispatch // previously capped at 64 and silently mis-called 130+-param synthesized // capture constructors (#5437). - debug_assert!( - param_count as usize <= MAX_VTABLE_DISPATCH_ARITY, - "call_vtable_method: param_count {} exceeds MAX_VTABLE_DISPATCH_ARITY", - param_count + // REAL runtime guard (all builds, not just debug): reject any arity past the + // dispatch cap BEFORE building the positional vec and invoking the + // trampoline. A `debug_assert!` alone is compiled out in release — exactly + // the bug class behind the original 64-cap miscompile (#5437), where an + // over-cap arity silently mis-called the fn pointer in release builds. Fail + // closed with a clear panic instead. + let param_count_usize = param_count as usize; + assert!( + param_count_usize <= MAX_VTABLE_DISPATCH_ARITY, + "call_vtable_method: param_count {} exceeds MAX_VTABLE_DISPATCH_ARITY ({})", + param_count, + MAX_VTABLE_DISPATCH_ARITY ); let mut positional: Vec = Vec::with_capacity(param_count as usize); for i in 0..(param_count as usize) {