diff --git a/CHANGELOG.md b/CHANGELOG.md index db8801a502..4d95eec4d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to elephc, a PHP-to-native compiler written in Rust. Releases are listed newest first. ## [Unreleased] +- Fixed a silent miscompile around indirect callable invocations (issue #487): the generated descriptor-invoker trampoline used callee-saved registers as scratch without preserving them, so any value the register allocator kept live across a closure / callable-string / first-class-callable call — most visibly the accumulator in `$acc += $f()` inside a loop — was clobbered and read back as the trampoline's leftover scratch. The invoker now saves and restores the callee-saved registers it touches, like every compiled function prologue (AArch64 x19–x26, x86_64 r12/rbx/r13–r15). - Fixed several PHP conformance gaps: `Exception`/`Error` constructors now accept and store `$previous` (`?Throwable`) so `getPrevious()` round-trips instead of always returning null; interface and parent method implementations may narrow self returns covariantly, including static methods; soft-keyword `enum` is accepted throughout class-like declarations, type hints, imports, and scoped expressions; `use const` handles lexer-tokenized globals in single, multiple, and grouped imports; and `foreach` over unknown `array` values preserves both integer and string keys instead of treating them as integers only. - Fixed heap corruption when arrays grown inside loops are promoted to mixed-element storage (issue #452). Loop analysis now covers `$a[] =`, `$a[$i] =`, and `array_push($a, …)` sites and uses inferred call and assignment value types before lowering, so every write uses consistent boxed storage without widening homogeneous typed rebuilds such as `MultipleIterator::detachIterator`. This also fixes the silent value corruption and per-conversion leak in the string/float form of the same pattern. - Fixed `match` and ternary expressions with heterogeneous result types (issues #488 and #494): object/array/string/int/float/`null` mixes now merge to a boxed `mixed` result and keep each value-producing branch's runtime type instead of coercing every branch into one scalar-biased unified type (fatal object-to-string casts, silent string/array coercions, or compile errors). Checker inference joins all branches — including assign→inferred-return and abbreviated `?:` shapes — and agrees with EIR lowering; nullable inferred returns preserve `null`, and `gettype()` on nullable-int merge temps no longer segfaults by unboxing an inline tagged scalar as a boxed Mixed cell. Same-representation merges (`int`/`bool`, indexed arrays with different element types) remain a documented PHP divergence. diff --git a/src/codegen/eval_callable_helpers.rs b/src/codegen/eval_callable_helpers.rs index 5998c73ee1..536e1512e9 100644 --- a/src/codegen/eval_callable_helpers.rs +++ b/src/codegen/eval_callable_helpers.rs @@ -1028,10 +1028,7 @@ fn store_descriptor_entry_incoming_arg( } else { let caller_offset = descriptor_entry_caller_stack_offset(emitter, stack_offset.expect("stack offset")); - let spill_reg = match emitter.target.arch { - crate::codegen::platform::Arch::AArch64 => "d15", - crate::codegen::platform::Arch::X86_64 => "xmm15", - }; + let spill_reg = abi::float_spill_scratch_reg(emitter.target); abi::load_from_caller_stack(emitter, spill_reg, caller_offset); spill_reg }; @@ -1100,10 +1097,7 @@ fn load_descriptor_entry_actual_arg( let reg = if assignment.in_register() { abi::float_arg_reg_name(emitter.target, assignment.start_reg) } else { - match emitter.target.arch { - crate::codegen::platform::Arch::AArch64 => "d15", - crate::codegen::platform::Arch::X86_64 => "xmm15", - } + abi::float_spill_scratch_reg(emitter.target) }; abi::load_at_offset(emitter, reg, offset); if let Some(out_offset) = stack_offset { diff --git a/src/codegen/lower_inst.rs b/src/codegen/lower_inst.rs index 0fa5828d13..c76b9cc3b8 100644 --- a/src/codegen/lower_inst.rs +++ b/src/codegen/lower_inst.rs @@ -1396,10 +1396,7 @@ fn store_descriptor_entry_incoming_arg( emitter, stack_offset.expect("stack offset"), ); - let spill_reg = match emitter.target.arch { - Arch::AArch64 => "d15", - Arch::X86_64 => "xmm15", - }; + let spill_reg = abi::float_spill_scratch_reg(emitter.target); abi::load_from_caller_stack(emitter, spill_reg, caller_offset); spill_reg }; @@ -1474,10 +1471,7 @@ fn load_descriptor_entry_actual_arg( let reg = if assignment.in_register() { abi::float_arg_reg_name(emitter.target, assignment.start_reg) } else { - match emitter.target.arch { - Arch::AArch64 => "d15", - Arch::X86_64 => "xmm15", - } + abi::float_spill_scratch_reg(emitter.target) }; abi::load_at_offset(emitter, reg, offset); if let Some(out_offset) = stack_offset { diff --git a/src/codegen/runtime_callable_invoker.rs b/src/codegen/runtime_callable_invoker.rs index a1c6f43cfe..508162ccd6 100644 --- a/src/codegen/runtime_callable_invoker.rs +++ b/src/codegen/runtime_callable_invoker.rs @@ -10,6 +10,10 @@ //! - Capture values are loaded from the callable descriptor, not caller frame state. //! - Argument materialization supports indexed arrays, associative arrays, defaults, variadics, //! by-reference marker cells, and target-aware ABI calls without depending on `Context`. +//! - The trampoline preserves the callee-saved registers it scratches (issue #487), including on +//! the eval exception-boundary escape path, so allocator-parked caller values survive the invoke. +//! - Exception-boundary slots use ABI frame helpers because the expanded save area pushes ARM64 +//! offsets beyond the signed 9-bit `ldur`/`stur` immediate range. use crate::codegen::callable_descriptor; use crate::codegen::callable_invoker_args::{ @@ -28,11 +32,49 @@ use crate::types::{FunctionSig, PhpType}; const INVOKER_DESCRIPTOR_OFFSET: usize = 8; const INVOKER_CONCAT_OFFSET: usize = 16; -const INVOKER_FRAME_SIZE: usize = 32; +/// Spill slot for the boxed argument container across the eval exception-boundary `setjmp`. const INVOKER_ARG_ARRAY_OFFSET: usize = 24; +/// First frame slot of the callee-saved register save area (issue #487). +/// Placed after descriptor/concat/arg-array so the eval boundary path can reuse those slots. +const INVOKER_SAVED_REGS_OFFSET: usize = 32; +/// AArch64 save-area width (8 callee-saved scratch regs × 8 bytes); sizes the shared frame. +const INVOKER_CALLEE_SAVE_BYTES: usize = 8 * 8; +/// Exclusive end of the callee-saved save area (`INVOKER_SAVED_REGS_OFFSET` … end-8). +const INVOKER_SAVED_REGS_END: usize = INVOKER_SAVED_REGS_OFFSET + INVOKER_CALLEE_SAVE_BYTES; +/// Frame size covering the footer plus every local slot through the save area. +/// `frame_size - 16` must cover the last save offset; rounded up to 16 for ABI `sp` alignment. +const INVOKER_FRAME_SIZE: usize = ((INVOKER_SAVED_REGS_END - 8) + 16 + 15) & !15; const INVOKER_BOUNDARY_FRAME_SIZE: usize = INVOKER_FRAME_SIZE + TRY_HANDLER_SLOT_SIZE + 16; const INVOKER_BOUNDARY_BASE_OFFSET: usize = INVOKER_BOUNDARY_FRAME_SIZE - 16; +/// Callee-saved registers the invoker body uses as scratch. Must stay in sync with the +/// hard-coded scratch choices in the indexed/assoc/mixed argument loaders and +/// `abi::nested_call_reg` (`x19` / `r12`). The register allocator parks caller values in +/// these registers across `callable_descriptor_invoke`, so the trampoline must preserve +/// them like any compiled function prologue (issue #487). +fn invoker_saved_callee_regs(arch: Arch) -> &'static [&'static str] { + match arch { + Arch::AArch64 => &["x19", "x20", "x21", "x22", "x23", "x24", "x25", "x26"], + Arch::X86_64 => &["r12", "rbx", "r13", "r14", "r15"], + } +} + +/// Stores the caller's callee-saved registers the invoker body is about to scratch. +fn emit_invoker_callee_saved_saves(emitter: &mut Emitter) { + for (index, reg) in invoker_saved_callee_regs(emitter.target.arch).iter().enumerate() { + abi::store_at_offset(emitter, reg, INVOKER_SAVED_REGS_OFFSET + index * 8); + } +} + +/// Reloads the caller's callee-saved registers after the invoker body finishes. +/// +/// The boxed Mixed result travels in the return registers, which these loads never touch. +fn emit_invoker_callee_saved_restores(emitter: &mut Emitter) { + for (index, reg) in invoker_saved_callee_regs(emitter.target.arch).iter().enumerate() { + abi::load_at_offset(emitter, reg, INVOKER_SAVED_REGS_OFFSET + index * 8); + } +} + /// Runtime invoker metadata emitted beside callable descriptors. pub(super) struct RuntimeCallableInvoker<'a> { pub(super) label: &'a str, @@ -116,6 +158,10 @@ fn emit_runtime_callable_invoker_impl( emitter.raw(".align 2"); emitter.label_global(invoker.label); abi::emit_frame_prologue(emitter, frame_size); + // Preserve the caller's callee-saved registers before the body scratches them: the + // register allocator keeps values live across the indirect invoke in these registers + // (issue #487). + emit_invoker_callee_saved_saves(emitter); abi::store_at_offset( emitter, abi::int_arg_reg_name(emitter.target, 0), @@ -156,12 +202,16 @@ fn emit_runtime_callable_invoker_impl( if catch_native_throws { emit_invoker_exception_boundary_pop(emitter, INVOKER_BOUNDARY_BASE_OFFSET); } + // Restore before tearing down the frame (and on the escape path below): the boxed + // Mixed result travels in return registers, which these loads never touch. + emit_invoker_callee_saved_restores(emitter); abi::emit_frame_restore(emitter, frame_size); abi::emit_return(emitter); if catch_native_throws { emitter.label(&escape_label); emit_invoker_exception_boundary_pop(emitter, INVOKER_BOUNDARY_BASE_OFFSET); emit_null_invoker_result(emitter); + emit_invoker_callee_saved_restores(emitter); abi::emit_frame_restore(emitter, frame_size); abi::emit_return(emitter); } @@ -196,22 +246,23 @@ fn emit_invoker_exception_boundary_push( match emitter.target.arch { Arch::AArch64 => { abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_handler_top", 0); - emitter.instruction(&format!("stur x10, [x29, #-{}]", handler_base)); // save the previous native exception-handler head + abi::store_at_offset(emitter, "x10", handler_base); abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_call_frame_top", 0); - emitter.instruction(&format!("stur x10, [x29, #-{}]", handler_base - 8)); // preserve the caller activation frame across callable unwinding + abi::store_at_offset(emitter, "x10", handler_base - 8); abi::emit_load_symbol_to_reg(emitter, "x10", "_rt_diag_suppression", 0); - emitter.instruction(&format!( - "stur x10, [x29, #-{}]", - handler_base - TRY_HANDLER_DIAG_DEPTH_OFFSET - )); // save diagnostic suppression depth for restoration - emitter.instruction(&format!("sub x10, x29, #{}", handler_base)); // compute the boundary handler record address + abi::store_at_offset( + emitter, + "x10", + handler_base - TRY_HANDLER_DIAG_DEPTH_OFFSET, + ); + emitter.instruction(&format!("sub x10, x29, #{}", handler_base)); // compute the boundary handler record address abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); emitter.instruction(&format!( "sub x0, x29, #{}", handler_base - TRY_HANDLER_JMP_BUF_OFFSET )); // pass the boundary jmp_buf to setjmp emitter.bl_c("setjmp"); // snapshot the bridge stack before entering the callable - emitter.instruction(&format!("cbnz x0, {}", escape_label)); // non-zero setjmp result means a callable Throwable escaped + emitter.instruction(&format!("cbnz x0, {}", escape_label)); // non-zero setjmp result means a callable Throwable escaped } Arch::X86_64 => { abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_handler_top", 0); @@ -230,8 +281,8 @@ fn emit_invoker_exception_boundary_push( handler_base - TRY_HANDLER_JMP_BUF_OFFSET )); // pass the boundary jmp_buf to setjmp emitter.bl_c("setjmp"); // snapshot the bridge stack before entering the callable - emitter.instruction("test eax, eax"); // did control arrive through longjmp? - emitter.instruction(&format!("jne {}", escape_label)); // non-zero setjmp result means a callable Throwable escaped + emitter.instruction("test eax, eax"); // did control arrive through longjmp? + emitter.instruction(&format!("jne {}", escape_label)); // non-zero setjmp result means a callable Throwable escaped } } } @@ -241,12 +292,13 @@ fn emit_invoker_exception_boundary_pop(emitter: &mut Emitter, handler_base: usiz emitter.comment("pop eval callable exception boundary"); match emitter.target.arch { Arch::AArch64 => { - emitter.instruction(&format!("ldur x10, [x29, #-{}]", handler_base)); // reload the previous native exception-handler head + abi::load_at_offset(emitter, "x10", handler_base); abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); - emitter.instruction(&format!( - "ldur x10, [x29, #-{}]", - handler_base - TRY_HANDLER_DIAG_DEPTH_OFFSET - )); // reload the saved diagnostic suppression depth + abi::load_at_offset( + emitter, + "x10", + handler_base - TRY_HANDLER_DIAG_DEPTH_OFFSET, + ); abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); } Arch::X86_64 => { @@ -265,10 +317,10 @@ fn emit_invoker_exception_boundary_pop(emitter: &mut Emitter, handler_base: usiz fn emit_null_invoker_result(emitter: &mut Emitter) { match emitter.target.arch { Arch::AArch64 => { - emitter.instruction("mov x0, xzr"); // return null so magician takes the pending Throwable + emitter.instruction("mov x0, xzr"); // return null so magician takes the pending Throwable } Arch::X86_64 => { - emitter.instruction("xor eax, eax"); // return null so magician takes the pending Throwable + emitter.instruction("xor eax, eax"); // return null so magician takes the pending Throwable } } } @@ -2424,3 +2476,36 @@ fn emit_call_user_func_array_missing_arg_abort(emitter: &mut Emitter, data: &mut } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::codegen::platform::{Platform, Target}; + + /// Verifies expanded ARM64 invoker boundaries materialize far frame-slot addresses. + #[test] + fn arm64_invoker_boundary_uses_large_offset_frame_helpers() { + let mut emitter = Emitter::new(Target::new(Platform::Linux, Arch::AArch64)); + + emit_invoker_exception_boundary_push( + &mut emitter, + INVOKER_BOUNDARY_BASE_OFFSET, + "invoker_escape", + ); + emit_invoker_exception_boundary_pop(&mut emitter, INVOKER_BOUNDARY_BASE_OFFSET); + + let output = emitter.output(); + assert!(INVOKER_BOUNDARY_BASE_OFFSET > 255); + for offset in [ + INVOKER_BOUNDARY_BASE_OFFSET, + INVOKER_BOUNDARY_BASE_OFFSET - 8, + INVOKER_BOUNDARY_BASE_OFFSET - TRY_HANDLER_DIAG_DEPTH_OFFSET, + ] { + assert!(output.contains(&format!(" sub x9, x29, #{}\n", offset))); + } + assert!(output.contains(" str x10, [x9]\n")); + assert!(output.contains(" ldr x10, [x9]\n")); + assert!(!output.contains("stur x10, [x29, #-3")); + assert!(!output.contains("ldur x10, [x29, #-3")); + } +} diff --git a/src/codegen_support/abi/calls/incoming.rs b/src/codegen_support/abi/calls/incoming.rs index ee8f3f336c..abc0ddd0e8 100644 --- a/src/codegen_support/abi/calls/incoming.rs +++ b/src/codegen_support/abi/calls/incoming.rs @@ -8,13 +8,13 @@ //! Key details: //! - Incoming cursor state must match outgoing assignment rules or calls will corrupt frame slots. -use crate::codegen_support::{emit::Emitter, platform::Arch}; +use crate::codegen_support::emit::Emitter; use crate::types::PhpType; use super::super::frame::{load_from_caller_stack, store_at_offset}; use super::super::registers::{ - float_arg_reg_limit, float_arg_reg_name, int_arg_reg_limit, int_arg_reg_name, - secondary_scratch_reg, tertiary_scratch_reg, IncomingArgCursor, + float_arg_reg_limit, float_arg_reg_name, float_spill_scratch_reg, int_arg_reg_limit, + int_arg_reg_name, secondary_scratch_reg, tertiary_scratch_reg, IncomingArgCursor, }; /// Stores a function parameter from the next available ABI register or caller stack slot @@ -34,10 +34,7 @@ pub fn emit_store_incoming_param( cursor: &mut IncomingArgCursor, ) { let ty = ty.codegen_repr(); - let float_spill_reg = match emitter.target.arch { - Arch::AArch64 => "d15", - Arch::X86_64 => "xmm15", - }; + let float_spill_reg = float_spill_scratch_reg(emitter.target); let int_spill_reg = secondary_scratch_reg(emitter); let int_hi_spill_reg = tertiary_scratch_reg(emitter); let int_reg_limit = int_arg_reg_limit(emitter.target); diff --git a/src/codegen_support/abi/calls/outgoing.rs b/src/codegen_support/abi/calls/outgoing.rs index 0b406022aa..6fb917ad41 100644 --- a/src/codegen_support/abi/calls/outgoing.rs +++ b/src/codegen_support/abi/calls/outgoing.rs @@ -16,8 +16,9 @@ use crate::types::PhpType; use super::super::frame::emit_adjust_sp; use super::super::registers::{ - float_arg_reg_limit, float_arg_reg_name, int_arg_reg_limit, int_arg_reg_name, - secondary_scratch_reg, tertiary_scratch_reg, OutgoingArgAssignment, STACK_ARG_SENTINEL, + float_arg_reg_limit, float_arg_reg_name, float_spill_scratch_reg, int_arg_reg_limit, + int_arg_reg_name, secondary_scratch_reg, tertiary_scratch_reg, OutgoingArgAssignment, + STACK_ARG_SENTINEL, }; use super::stack::{emit_load_temporary_stack_slot, emit_store_to_sp}; @@ -109,10 +110,7 @@ fn emit_copy_stack_arg_slot( Arch::AArch64 => tertiary_scratch_reg(emitter), Arch::X86_64 => "r11", }; - let float_reg = match emitter.target.arch { - Arch::AArch64 => "d15", - Arch::X86_64 => "xmm15", - }; + let float_reg = float_spill_scratch_reg(emitter.target); match ty { PhpType::Float => { emit_load_temporary_stack_slot(emitter, float_reg, src_offset); diff --git a/src/codegen_support/abi/mod.rs b/src/codegen_support/abi/mod.rs index 33e3a4851e..8e9da261f5 100644 --- a/src/codegen_support/abi/mod.rs +++ b/src/codegen_support/abi/mod.rs @@ -40,8 +40,9 @@ pub use frame::{ #[cfg(test)] pub use frame::{emit_preserve_return_value, emit_restore_return_value}; pub(crate) use registers::{ - float_arg_reg_name, float_result_reg, int_arg_reg_name, int_result_reg, secondary_scratch_reg, - string_result_regs, symbol_scratch_reg, tertiary_scratch_reg, + float_arg_reg_name, float_result_reg, float_spill_scratch_reg, int_arg_reg_name, + int_result_reg, secondary_scratch_reg, string_result_regs, symbol_scratch_reg, + tertiary_scratch_reg, }; pub use registers::{ nested_call_reg, process_argc_reg, process_argv_reg, temp_int_reg, IncomingArgCursor, diff --git a/src/codegen_support/abi/registers.rs b/src/codegen_support/abi/registers.rs index 25686e5e6d..fe1f3f7558 100644 --- a/src/codegen_support/abi/registers.rs +++ b/src/codegen_support/abi/registers.rs @@ -67,6 +67,15 @@ pub(crate) fn float_arg_reg_name(target: Target, idx: usize) -> &'static str { } } +/// Returns the caller-saved floating-point scratch register reserved for ABI spill staging. +/// AArch64 uses d31 so staging never clobbers the callee-saved d8-d15 range; x86_64 uses xmm15. +pub(crate) fn float_spill_scratch_reg(target: Target) -> &'static str { + match target.arch { + Arch::AArch64 => "d31", + Arch::X86_64 => "xmm15", + } +} + /// Returns the register used to hold integer/pointer function results. /// AArch64: x0. x86_64: rax. pub(crate) fn int_result_reg(emitter: &Emitter) -> &'static str { diff --git a/src/codegen_support/abi/tests/arguments.rs b/src/codegen_support/abi/tests/arguments.rs index 2ab6afb95c..94eff2e5e6 100644 --- a/src/codegen_support/abi/tests/arguments.rs +++ b/src/codegen_support/abi/tests/arguments.rs @@ -32,6 +32,32 @@ fn test_emit_store_incoming_param_uses_registers_then_caller_stack() { assert!(out.contains(" ldr x10, [x29, #32]\n")); } +/// Verifies that an incoming AArch64 float overflow uses caller-saved d31 rather than d15. +#[test] +fn test_emit_store_incoming_float_overflow_uses_volatile_scratch() { + let mut emitter = test_emitter(); + let mut cursor = IncomingArgCursor::default(); + + for (index, name) in ["a", "b", "c", "d", "e", "f", "g", "h", "i"] + .iter() + .enumerate() + { + emit_store_incoming_param( + &mut emitter, + name, + &PhpType::Float, + (index + 1) * 8, + false, + &mut cursor, + ); + } + + let out = emitter.output(); + assert!(out.contains(" ; param $i from caller stack +32\n")); + assert!(out.contains(" ldr d31, [x29, #32]\n")); + assert!(!out.contains("d15")); +} + /// Tests that `emit_frame_slot_address` correctly handles offsets larger than /// a single immediate instruction can encode by emitting multiple sub instructions /// to reach offsets like 5000 on ARM64. @@ -123,6 +149,26 @@ fn test_materialize_outgoing_args_keeps_overflow_on_stack() { assert!(out.contains(" add sp, sp, #160\n")); } +/// Verifies that outgoing AArch64 float overflow staging never clobbers callee-saved d15. +#[test] +fn test_materialize_outgoing_float_overflow_uses_volatile_scratch() { + let mut emitter = test_emitter(); + let arg_types = vec![PhpType::Float; 9]; + let assignments = build_outgoing_arg_assignments_for_target( + Target::new(Platform::MacOS, Arch::AArch64), + &arg_types, + 0, + ); + + let overflow_bytes = materialize_outgoing_args(&mut emitter, &assignments); + let out = emitter.output(); + + assert_eq!(overflow_bytes, 16); + assert!(out.contains(" ldr d31, [sp, #32]\n")); + assert!(out.contains(" str d31, [sp]\n")); + assert!(!out.contains("d15")); +} + /// Tests that `emit_store_local_slot_to_symbol` handles string slots with large /// offsets (>4095) by emitting the necessary adrp/add page calculations and /// decomposed sub instructions to reach the slot, then stores both x10 and x11 diff --git a/tests/codegen/callables/expr_calls.rs b/tests/codegen/callables/expr_calls.rs index 498bfa1fe3..a768e2cd84 100644 --- a/tests/codegen/callables/expr_calls.rs +++ b/tests/codegen/callables/expr_calls.rs @@ -1452,6 +1452,116 @@ echo $cb(); assert_eq!(out, "B"); } +/// Regression for #487: a value the register allocator parks in a callee-saved register +/// across an indirect callable invoke (here the loaded LHS of `$acc += $f()`) must survive +/// the call. The generated descriptor-invoker trampoline used callee-saved registers as +/// scratch without saving them, so the accumulator read back as the trampoline's leftover +/// (the empty args-array length, 0) and only the last call's value survived. +#[test] +fn test_compound_assign_closure_call_rhs_accumulates() { + let out = compile_and_run( + r#"p += $f(); +} +echo $acc, "|", $b->p; +"#, + ); + assert_eq!(out, "80|80"); +} + +/// Verifies that indirect calls with float overflow arguments use only volatile FP scratch state. +#[test] +fn test_indirect_float_overflow_uses_volatile_scratch() { + let dir = make_cli_test_dir("elephc_indirect_float_overflow_volatile_scratch"); + let (user_asm, _runtime_asm, required_libraries) = compile_source_to_asm_with_options( + r#" { + assert!( + user_asm.contains("d31"), + "AArch64 float overflow should exercise d31 staging:\n{}", + user_asm + ); + assert!( + !user_asm.contains("d15"), + "AArch64 call lowering must preserve callee-saved d15:\n{}", + user_asm + ); + } + Arch::X86_64 => assert!( + user_asm.contains("xmm15"), + "x86_64 float overflow should exercise xmm15 staging:\n{}", + user_asm + ), + } + + let out = assemble_and_run( + &user_asm, + get_runtime_obj(), + &dir, + &required_libraries, + &default_link_paths(), + &[], + ); + assert_eq!(out, "45"); + + let _ = fs::remove_dir_all(&dir); +} + /// Verifies that `call_user_func` dispatches a boxed-Mixed callback by runtime tag. /// /// A callable stored in a `mixed` property is read back boxed, so the checker sees