From 51977993f3ebfdf5cb8c76015f772e06024598a8 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Fri, 15 May 2026 10:02:43 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20#776=20=E2=80=94=20implicit-return?= =?UTF-8?q?=20functions=20now=20produce=20undefined,=20not=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A function that falls off the end without an explicit `return` was emitting `ret double 0.0`, which under NaN-boxing is the number 0 — not `undefined` (TAG_UNDEFINED = 0x7FFC_0000_0000_0001). Code like `f() === undefined` or `typeof f()` therefore disagreed with JS semantics. Replace every implicit-return / bare-`return;` site with the NaN-boxed TAG_UNDEFINED literal (sync + async, regular functions, closures, methods, static methods). Async epilogues also resolve the promise with `undefined` instead of `0`. With the bug fixed, the class-decorator return-value guard added in PR #754 can be tightened from `typeof ret === "function"` to `ret !== undefined`, catching primitive and plain-object replacement returns as well. Workaround comment dropped, error message generalized, parity expectation updated to match. --- crates/perry-codegen/src/codegen.rs | 39 +++++++++++-------- crates/perry-codegen/src/stmt.rs | 13 ++++--- crates/perry-hir/src/lower.rs | 21 +++------- ...est_decorators_replacement_unsupported.txt | 2 +- 4 files changed, 36 insertions(+), 39 deletions(-) diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 0328f945..8b437ddb 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -3256,21 +3256,22 @@ fn compile_function( stmt::lower_stmts(&mut ctx, &f.body) .with_context(|| format!("lowering body of '{}'", f.name))?; - // Defensive: a well-typed numeric function always returns via an - // explicit `return`, but we emit `ret double 0.0` as a fallback so - // the LLVM verifier doesn't reject a missing terminator. For - // async functions, the fallback also wraps in a resolved promise - // so callers can await the result. + // A function that falls off the end without an explicit `return` + // returns `undefined` in JS — emit the NaN-boxed TAG_UNDEFINED + // value so the LLVM verifier has a terminator AND user code that + // does `f() === undefined` / `f() !== undefined` observes the + // correct value. For async functions, wrap undefined in a + // resolved promise so callers can await the result. if !ctx.block().is_terminated() { + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); if f.is_async { - let zero = "0.0".to_string(); let handle = ctx .block() - .call(I64, "js_promise_resolved", &[(DOUBLE, &zero)]); + .call(I64, "js_promise_resolved", &[(DOUBLE, &undef)]); let boxed = crate::expr::nanbox_pointer_inline_pub(ctx.block(), &handle); ctx.block().ret(DOUBLE, &boxed); } else { - ctx.block().ret(DOUBLE, "0.0"); + ctx.block().ret(DOUBLE, &undef); } } let ic_globals = std::mem::take(&mut ctx.ic_globals); @@ -3598,15 +3599,15 @@ fn compile_closure( .with_context(|| format!("lowering closure body func_id={}", func_id))?; if !ctx.block().is_terminated() { + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); if is_async { - let zero = "0.0".to_string(); let handle = ctx .block() - .call(I64, "js_promise_resolved", &[(DOUBLE, &zero)]); + .call(I64, "js_promise_resolved", &[(DOUBLE, &undef)]); let boxed = crate::expr::nanbox_pointer_inline_pub(ctx.block(), &handle); ctx.block().ret(DOUBLE, &boxed); } else { - ctx.block().ret(DOUBLE, "0.0"); + ctx.block().ret(DOUBLE, &undef); } } let ic_globals = std::mem::take(&mut ctx.ic_globals); @@ -3898,9 +3899,12 @@ fn compile_method( stmt::lower_stmts(&mut ctx, &method.body).with_context(|| { format!("lowering body of method '{}::{}'", class.name, method.name) })?; - // Fall through to the default ret-void at end. + // Fall through to the default ret at end. if !ctx.block().is_terminated() { - ctx.block().ret(DOUBLE, "0.0"); + let undef = crate::nanbox::double_literal(f64::from_bits( + crate::nanbox::TAG_UNDEFINED, + )); + ctx.block().ret(DOUBLE, &undef); } let _ = std::mem::take(&mut ctx.ic_globals); let _ = std::mem::take(&mut ctx.typed_parse_rodata); @@ -3978,7 +3982,8 @@ fn compile_method( .with_context(|| format!("lowering body of method '{}::{}'", class.name, method.name))?; if !ctx.block().is_terminated() { - ctx.block().ret(DOUBLE, "0.0"); + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.block().ret(DOUBLE, &undef); } let ic_globals = std::mem::take(&mut ctx.ic_globals); let typed_parse_rodata = std::mem::take(&mut ctx.typed_parse_rodata); @@ -5389,15 +5394,15 @@ fn compile_static_method( .with_context(|| format!("lowering body of static '{}::{}'", class_name, f.name))?; if !ctx.block().is_terminated() { + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); if f.is_async { - let zero = "0.0".to_string(); let handle = ctx .block() - .call(I64, "js_promise_resolved", &[(DOUBLE, &zero)]); + .call(I64, "js_promise_resolved", &[(DOUBLE, &undef)]); let boxed = crate::expr::nanbox_pointer_inline_pub(ctx.block(), &handle); ctx.block().ret(DOUBLE, &boxed); } else { - ctx.block().ret(DOUBLE, "0.0"); + ctx.block().ret(DOUBLE, &undef); } } let ic_globals = std::mem::take(&mut ctx.ic_globals); diff --git a/crates/perry-codegen/src/stmt.rs b/crates/perry-codegen/src/stmt.rs index 7a79bdde..6ca95096 100644 --- a/crates/perry-codegen/src/stmt.rs +++ b/crates/perry-codegen/src/stmt.rs @@ -106,12 +106,15 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { Ok(()) } Stmt::Return(None) => { - // Bare `return;` returns undefined (encoded as 0.0). For - // async functions, wrap undefined in a resolved promise. + // Bare `return;` returns the NaN-boxed `undefined` value + // (TAG_UNDEFINED). For async functions, wrap it in a + // resolved promise. + let undef = + crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); if ctx.is_async_fn { - let zero = "0.0".to_string(); let blk = ctx.block(); - let handle = blk.call(crate::types::I64, "js_promise_resolved", &[(DOUBLE, &zero)]); + let handle = + blk.call(crate::types::I64, "js_promise_resolved", &[(DOUBLE, &undef)]); let boxed = crate::expr::nanbox_pointer_inline_pub(blk, &handle); // Pop open try frames first (see above). for _ in 0..ctx.try_depth { @@ -123,7 +126,7 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { for _ in 0..ctx.try_depth { ctx.block().call_void("js_try_end", &[]); } - ctx.block().ret(DOUBLE, "0.0"); + ctx.block().ret(DOUBLE, &undef); } Ok(()) } diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index 4b023f47..b2b56a8d 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -3894,27 +3894,16 @@ fn append_decorator_invocations_inner( init: Some(call), }); let msg = format!( - "Class decorator `@{dec_name}` on `{class_name}` returned a replacement \ -class. Perry does not install class replacements from decorators (see \ + "Class decorator `@{dec_name}` on `{class_name}` returned a value. \ +Perry does not install decorator return values as class replacements (see \ docs/src/language/decorators.md). Return `undefined` (or nothing) to keep \ the decorator running for side effects only." ); - // Check `typeof ret === "function"` rather than - // `ret !== undefined`: Perry's lowering for a function - // expression with no explicit `return` currently leaves a - // numeric sentinel in the return slot rather than the - // NaN-boxed undefined value, so `!== undefined` would - // false-positive on side-effect-only decorators. The - // semantic check the maintainer asked for is "did the - // decorator return a class?" — a class is `typeof - // "function"` in JS, so this catches the @Memoize / - // @Throttle / GraphQL-wrapper case while leaving the bare - // `@Injectable` (no return) shape alone. out.push(Stmt::If { condition: Expr::Compare { - op: CompareOp::Eq, - left: Box::new(Expr::TypeOf(Box::new(Expr::LocalGet(ret_id)))), - right: Box::new(Expr::String("function".to_string())), + op: CompareOp::Ne, + left: Box::new(Expr::LocalGet(ret_id)), + right: Box::new(Expr::Undefined), }, // Perry has dedicated HIR variants for built-in errors // (`Expr::TypeErrorNew`, etc.); the generic diff --git a/test-parity/expected/test_decorators_replacement_unsupported.txt b/test-parity/expected/test_decorators_replacement_unsupported.txt index a5e4fd92..5abdcd4f 100644 --- a/test-parity/expected/test_decorators_replacement_unsupported.txt +++ b/test-parity/expected/test_decorators_replacement_unsupported.txt @@ -1,2 +1,2 @@ -TypeError: Class decorator `@ReplaceWithOther` on `Original` returned a replacement class. Perry does not install class replacements from decorators (see docs/src/language/decorators.md). Return `undefined` (or nothing) to keep the decorator running for side effects only. +TypeError: Class decorator `@ReplaceWithOther` on `Original` returned a value. Perry does not install decorator return values as class replacements (see docs/src/language/decorators.md). Return `undefined` (or nothing) to keep the decorator running for side effects only. at From 2811c88e2658e96ccfe552e89ff4138d8fc0f635 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Fri, 15 May 2026 10:18:09 +0200 Subject: [PATCH 2/2] fmt: cargo fmt --- crates/perry-codegen/src/stmt.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/perry-codegen/src/stmt.rs b/crates/perry-codegen/src/stmt.rs index 6ca95096..e047147a 100644 --- a/crates/perry-codegen/src/stmt.rs +++ b/crates/perry-codegen/src/stmt.rs @@ -109,12 +109,14 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { // Bare `return;` returns the NaN-boxed `undefined` value // (TAG_UNDEFINED). For async functions, wrap it in a // resolved promise. - let undef = - crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); if ctx.is_async_fn { let blk = ctx.block(); - let handle = - blk.call(crate::types::I64, "js_promise_resolved", &[(DOUBLE, &undef)]); + let handle = blk.call( + crate::types::I64, + "js_promise_resolved", + &[(DOUBLE, &undef)], + ); let boxed = crate::expr::nanbox_pointer_inline_pub(blk, &handle); // Pop open try frames first (see above). for _ in 0..ctx.try_depth {