From 2de545239646d9067865af67202a9c345976e842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 14 Jul 2026 09:47:28 +0200 Subject: [PATCH] fix(hir): classify Function.apply/.call with an unfoldable body as an eval surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Function.apply(thisArg, args)` and `Function.call(thisArg, …, body)` reach CreateDynamicFunction indirectly — the same surface as a direct `Function(body)`, just spelled through the constructor's own `apply`/`call`. The eval classifier only recognized an *identifier* callee, so those sites were never classified: an unfoldable body fell through to the generic call lowering and evaluated to `undefined`. The caller then invoked `.apply` on that `undefined` and failed several frames away with TypeError: Function.prototype.apply was called on a value that is not a function which names neither eval nor the real cause. Perry cannot compile a body assembled from runtime data — a real ahead-of-time limitation — but it must say so at the offending site. These callees are now recognized, so an unfoldable body lowers to the deferred, located "cannot run in an ahead-of-time compiled binary" error: compilation still succeeds, the notice is printed, and it throws only if the site is actually reached. Function.apply(null, argNames.concat(body)) // before: undefined → "apply was called on a value that is not a function" // after: new Function() cannot run in an ahead-of-time compiled binary (app.ts:11) Const-foldable spellings are unaffected — `try_eval_function_member_call_fold` runs first, so `Function("a","b","return a+b")`, `Function.apply(null, [])` and `Function.call(null, …)` still compile natively and evaluate exactly as in node. For `apply`, the body is the array's last element when the array is a literal; when the list is assembled at runtime the array expression itself stands in, which is non-constant by construction, so the classifier buckets the site runtime-unknown. mysql2's row-parser codegen is precisely this shape — `Function.apply(null, argNames.concat(body)).apply(null, argValues)` — so a real MySQL query died pointing at `apply` instead of at runtime code generation. (mysql2 ships `disableEval: true` for no-codegen environments, which is the correct setting under an AOT compiler; this change makes the diagnosis obvious rather than the failure mysterious.) Covered by `function_apply_dynamic_args_eval_surface`: the literal forms still evaluate, the dynamic site throws a catchable Error naming the AOT limitation, and it never degrades into the bogus `.apply` failure. --- .../lower/expr_call/intrinsics/eval_strict.rs | 70 ++++++- ...unction_apply_dynamic_args_eval_surface.rs | 179 ++++++++++++++++++ 2 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 crates/perry/tests/function_apply_dynamic_args_eval_surface.rs diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs index 16226eeecc..1513d3a010 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/eval_strict.rs @@ -32,12 +32,49 @@ pub(crate) fn check_eval_function_call( while let ast::Expr::Paren(p) = callee { callee = p.expr.as_ref(); } - let ast::Expr::Ident(ident) = callee else { - return Ok(None); + // `Function.apply(thisArg, argsArray)` / `Function.call(thisArg, …, body)` reach + // CreateDynamicFunction indirectly — the same surface as a direct `Function(body)`, + // just spelled through the constructor's own `apply`/`call`. Recognize them here so + // an unfoldable body is classified. Otherwise the site fell through to the generic + // call lowering and evaluated to `undefined`; the caller then invoked `.apply` on + // that `undefined` and failed several frames away with a misleading + // "Function.prototype.apply was called on a value that is not a function", naming + // neither eval nor the real cause. mysql2's row-parser codegen is exactly this + // shape: `Function.apply(null, argNames.concat(body)).apply(null, argValues)`. + // + // A const-foldable spelling never reaches here — `try_eval_function_member_call_fold` + // runs first and compiles it. + let mut member_apply = false; + let ident = match callee { + ast::Expr::Ident(id) => id, + ast::Expr::Member(m) => { + let ast::MemberProp::Ident(prop) = &m.prop else { + return Ok(None); + }; + match prop.sym.as_ref() { + "apply" => member_apply = true, + "call" => {} + _ => return Ok(None), + } + let mut obj = m.obj.as_ref(); + while let ast::Expr::Paren(p) = obj { + obj = p.expr.as_ref(); + } + let ast::Expr::Ident(id) = obj else { + return Ok(None); + }; + if id.sym.as_ref() != "Function" { + return Ok(None); + } + id + } + _ => return Ok(None), }; let name = ident.sym.as_ref(); let surface = match name { - "eval" => crate::eval_classifier::EvalSurface::Eval, + "eval" if !member_apply && !matches!(callee, ast::Expr::Member(_)) => { + crate::eval_classifier::EvalSurface::Eval + } "Function" => crate::eval_classifier::EvalSurface::FunctionCall, _ => return Ok(None), }; @@ -52,11 +89,28 @@ pub(crate) fn check_eval_function_call( // Body argument: the only arg for `eval(code)`, the last arg for // `Function(p1, p2, body)`. A spread in the body position yields a // non-constant inner expr → the classifier buckets it runtime-unknown. - let body_arg = match surface { - crate::eval_classifier::EvalSurface::Eval => call.args.first(), - _ => call.args.last(), - } - .map(|a| a.expr.as_ref()); + // + // `Function.call(thisArg, p1, …, body)` — CreateDynamicFunction ignores its `this`, + // so the body is still the last argument. `Function.apply(thisArg, argsArray)` — + // the body is the array's last element when the array is a literal; when the list is + // assembled at runtime the array expression itself stands in, which is by + // construction non-constant, so the classifier buckets the site runtime-unknown. + let body_arg = if member_apply { + match call.args.get(1).map(|a| a.expr.as_ref()) { + Some(ast::Expr::Array(arr)) => arr + .elems + .last() + .and_then(|e| e.as_ref()) + .map(|e| e.expr.as_ref()), + other => other, + } + } else { + match surface { + crate::eval_classifier::EvalSurface::Eval => call.args.first(), + _ => call.args.last(), + } + .map(|a| a.expr.as_ref()) + }; match crate::eval_classifier::check_site(surface, body_arg, &ctx.source_file_path, call.span)? { crate::eval_classifier::EvalDecision::Proceed => Ok(None), crate::eval_classifier::EvalDecision::DeferToRuntimeError(message) => Ok(Some( diff --git a/crates/perry/tests/function_apply_dynamic_args_eval_surface.rs b/crates/perry/tests/function_apply_dynamic_args_eval_surface.rs new file mode 100644 index 0000000000..7ecace62c2 --- /dev/null +++ b/crates/perry/tests/function_apply_dynamic_args_eval_surface.rs @@ -0,0 +1,179 @@ +//! `Function.apply(null, )` is a CreateDynamicFunction +//! surface, exactly like `new Function(body)` — the constructor is merely reached +//! indirectly. Perry cannot compile a body built from runtime data, so the site must +//! be classified as an eval surface and lowered to the deferred, located +//! "cannot run in an ahead-of-time compiled binary" error. +//! +//! Before the fix the classifier only recognized `Function.apply(this, [])`. A runtime-built argument list fell through to the generic lowering and +//! evaluated to `undefined`; the caller then invoked `.apply` on that `undefined` and +//! failed several frames away with a misleading "Function.prototype.apply was called +//! on a value that is not a function". mysql2's row-parser codegen is exactly this +//! shape — `Function.apply(null, argNames.concat(body)).apply(null, argValues)` — +//! so a real MySQL query died with an error naming neither eval nor the real cause. +//! +//! Literal-source forms must keep working: those are const-folded and compiled AOT. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +/// Build `libperry_{runtime,stdlib}.a` once so the compiled binaries can link. +/// `perry-runtime` / `perry-stdlib` are rlib-only (#5422) — the archives come from the +/// `-static` wrapper crates, so building the plain crates leaves no `.a` in +/// `target/debug` and the link silently falls back to whatever stale archive is around. +fn ensure_runtime_archive() { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static") + .arg("-p") + .arg("perry-stdlib-static") + .output() + .expect("run cargo build -p perry-runtime-static -p perry-stdlib-static"); + assert!( + build.status.success(), + "cargo build of the static runtime wrappers failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +fn runtime_dir() -> PathBuf { + ensure_runtime_archive(); + target_debug_dir() +} + +/// Literal-source `Function` / `Function.apply` / `Function.call` are const-folded and +/// compiled; only the runtime-assembled argument list is a deferred eval surface. The +/// `mysql2` arm mirrors that library's codegen: build the arg list with `.concat()`, +/// hand it to `Function.apply`, then `.apply` the result — the shape that used to +/// surface as "Function.prototype.apply was called on a value that is not a function". +const MAIN_FIXTURE: &str = r#" +const add: any = Function("a", "b", "return a + b;"); +console.log("LITERAL_FN:" + add(1, 2)); + +const sub: any = Function.apply(null, ["a", "b", "return a - b;"]); +console.log("LITERAL_APPLY:" + sub(9, 4)); + +const mul: any = Function.call(null, "a", "b", "return a * b;"); +console.log("LITERAL_CALL:" + mul(3, 4)); + +if (process.argv.indexOf("--dynamic") !== -1) { + const body: string = ["return", "a", "+", "100;"].join(" "); + const argNames: string[] = ["a"]; + try { + const compiled: any = Function.apply(null, argNames.concat(body)); + const r = compiled.apply(null, [1]); + console.log("NO_THROW:" + r); + } catch (e: any) { + console.log("CAUGHT:" + (e && e.message)); + } +} +console.log("DONE"); +"#; + +fn write_fixture(root: &std::path::Path) { + std::fs::write(root.join("main.ts"), MAIN_FIXTURE).expect("write main.ts"); +} + +fn compile(root: &std::path::Path, extra_args: &[&str]) -> std::process::Output { + let entry = root.join("main.ts"); + let output = root.join("main_bin"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .env("PERRY_NO_AUTO_OPTIMIZE", "1"); + for a in extra_args { + cmd.arg(a); + } + cmd.output().expect("run perry compile") +} + +#[test] +fn function_apply_with_runtime_args_defers_to_a_located_aot_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_fixture(root); + + let out = compile(root, &[]); + assert!( + out.status.success(), + "compilation must succeed (deferred, not refused)\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + let bin = root.join("main_bin"); + + // Never reaching the dynamic site: the program runs, and the const-foldable + // literal-source forms still compile and evaluate exactly as in node. + let run = Command::new(&bin).output().expect("run compiled binary"); + assert!( + run.status.success(), + "binary must run when the dynamic site is not reached" + ); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + stdout.contains("LITERAL_FN:3") + && stdout.contains("LITERAL_APPLY:5") + && stdout.contains("LITERAL_CALL:12") + && stdout.contains("DONE"), + "literal-source Function/apply/call must still be compiled AOT; got:\n{stdout}" + ); + + // Reaching it throws a catchable, descriptive Error — NOT `undefined` flowing on + // into a bogus "apply was called on a value that is not a function". + let run2 = Command::new(&bin) + .arg("--dynamic") + .output() + .expect("run compiled binary --dynamic"); + assert!( + run2.status.success(), + "the binary must not crash when the dynamic Function site is reached" + ); + let stdout2 = String::from_utf8_lossy(&run2.stdout); + assert!( + stdout2.contains("CAUGHT:"), + "the runtime-assembled Function.apply must throw a catchable Error; got:\n{stdout2}" + ); + assert!( + stdout2.contains("cannot run in an ahead-of-time compiled binary"), + "the thrown Error must name the AOT limitation; got:\n{stdout2}" + ); + assert!( + !stdout2.contains("was called on a value that is not a function"), + "must NOT degrade into `undefined` and fail later inside `.apply`; got:\n{stdout2}" + ); + assert!( + !stdout2.contains("NO_THROW"), + "the dynamic Function site must not silently produce a value; got:\n{stdout2}" + ); +}