diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs index 38e4ab165a..71833e7fd1 100644 --- a/crates/perry-codegen/src/lower_call/func_ref.rs +++ b/crates/perry-codegen/src/lower_call/func_ref.rs @@ -975,6 +975,22 @@ pub fn try_lower_func_ref_call( let (values, guard) = super::lower_call_args_rooted(ctx, args)?; arg_group = guard; lowered.extend(values); + // #8770: pad missing trailing args with TAG_UNDEFINED, exactly like + // the cross-module twin (`extern_func.rs`, issue #608 arm). The callee + // is compiled with `declared_count` double parameters and its + // default-parameter lowering tests each for `undefined`; an + // under-applied same-module call site that emits only the provided + // args leaves the remaining FP argument registers holding caller-saved + // garbage, which the callee then reads as JS values. On the Claude + // Code bundle (one giant module, so EVERY direct call resolves here) + // `aP([q])` for `function aP(q, K = !1, _)` handed `K`/`_` whatever + // d1/d2 held after `js_array_from_values` — the #8770 poison values + // (0xffffffffffffffff receivers → shape_is_url_search_params / + // js_is_truthy faults, corrupted async iteration → unsettled awaits). + let undefined_lit = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + while lowered.len() < declared_count { + lowered.push(undefined_lit.clone()); + } } let arg_slices: Vec<(crate::types::LlvmType, &str)> = lowered.iter().map(|s| (DOUBLE, s.as_str())).collect(); diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 31b57ee073..aef67758c6 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -60,6 +60,10 @@ mod field_init; mod func_ref; #[cfg(test)] mod pipeline_call_tests; +/// #8770: an under-applied same-module direct call must pad the missing +/// trailing parameters with `TAG_UNDEFINED` (the cross-module arm always did). +#[cfg(test)] +mod underapply_pad_tests; pub(crate) use func_ref::{ guarded_call_return_proof, guarded_discriminant_branch_proofs, guarded_expr_proof, guarded_path_type, diff --git a/crates/perry-codegen/src/lower_call/underapply_pad_tests.rs b/crates/perry-codegen/src/lower_call/underapply_pad_tests.rs new file mode 100644 index 0000000000..f8127da4fe --- /dev/null +++ b/crates/perry-codegen/src/lower_call/underapply_pad_tests.rs @@ -0,0 +1,108 @@ +//! #8770 regression: a SAME-MODULE direct call with fewer arguments than the +//! callee's declared parameter count must pad the missing trailing parameters +//! with `TAG_UNDEFINED` — exactly like the cross-module twin +//! (`extern_func.rs`, issue #608 arm) always has. +//! +//! Without the padding, the callee (compiled with `declared_count` double +//! parameters, its default-parameter lowering testing each for `undefined`) +//! reads whatever the caller-saved FP argument registers happen to hold. On +//! the Claude Code bundle — one giant module, so every direct call resolves +//! through the same-module arm — `aP([q])` for `function aP(q, K = !1, _)` +//! handed `K`/`_` the leftovers of `js_array_from_values`' internals: +//! impossible-NaN bit patterns (`0xffffffffffffffff`) that flowed into +//! truthiness tests and method receivers (`_.get(A)`) and crashed in +//! `shape_is_url_search_params` / `js_is_truthy`, or silently corrupted the +//! async iteration ("Detected unsettled top-level await"). + +use crate::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, Param, Stmt}; + +fn param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn function(id: u32, name: &str, params: Vec, body: Vec) -> Function { + Function { + id, + name: name.to_string(), + type_params: Vec::new(), + params, + return_type: Type::Any, + body, + 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(), + } +} + +/// `function callee(a, b, c) { return b; }` called as `callee(7)`. +fn underapplied_call_ir() -> String { + let callee = function( + 1, + "callee", + vec![param(10, "a"), param(11, "b"), param(12, "c")], + vec![Stmt::Return(Some(Expr::LocalGet(11)))], + ); + let caller = function( + 2, + "caller", + Vec::new(), + vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::Number(7.0)], + type_args: Vec::new(), + byte_offset: 0, + }))], + ); + let mut module = Module::new("underapply_pad_test.ts"); + module.functions = vec![callee, caller]; + let opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + String::from_utf8(compile_module(&module, opts).expect("call fixture must compile")) + .expect("LLVM IR is UTF-8") +} + +#[test] +fn an_underapplied_direct_call_pads_missing_params_with_undefined() { + let ir = underapplied_call_ir(); + let undefined_lit = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + // The direct call must carry all three declared parameters… + let call_line = ir + .lines() + .find(|line| { + line.contains("call double @perry_fn_underapply_pad_test_ts__callee(") + }) + .unwrap_or_else(|| panic!("expected a direct call to the callee:\n{ir}")); + let args = call_line + .split("callee(") + .nth(1) + .map(|tail| tail.matches("double").count()) + .unwrap_or(0); + assert!( + args >= 3, + "an under-applied direct call must pass every declared parameter \ + (got {args} double args): {call_line}\n{ir}" + ); + // …and the missing trailing two must be the TAG_UNDEFINED literal. + assert!( + call_line.matches(undefined_lit.as_str()).count() >= 2, + "the two omitted parameters must be padded with the undefined literal \ + {undefined_lit}: {call_line}\n{ir}" + ); +}