Skip to content

fix(codegen): callable-invoker trampoline preserves callee-saved registers (#487)#490

Open
mirchaemanuel wants to merge 1 commit into
illegalstudio:mainfrom
mirchaemanuel:fix/487-compound-assign-closure-call
Open

fix(codegen): callable-invoker trampoline preserves callee-saved registers (#487)#490
mirchaemanuel wants to merge 1 commit into
illegalstudio:mainfrom
mirchaemanuel:fix/487-compound-assign-closure-call

Conversation

@mirchaemanuel

Copy link
Copy Markdown
Contributor

Summary

Fixes #487. Compound assignment with an indirect callable RHS silently miscompiled: $acc += $f() in a loop accumulated only the last call's value (4 instead of 80), with the closure demonstrably running every iteration. Every indirect form was affected — closures, arrow functions, callable strings, first-class callables, method FCCs — with any operator, compound or not, including property targets.

Root cause

An ABI violation in the generated descriptor-invoker trampoline (src/codegen/runtime_callable_invoker.rs): it saved only fp/lr and then used callee-saved registers as raw scratch (AArch64 x19–x26 via the nested-call register and the argument loaders; x86_64 r12/rbx/r13–r15). The linear register allocator relies on the platform ABI and parks values that live across a callable_descriptor_invoke in exactly those registers — so the loaded LHS of $acc += $f() was clobbered by the invoke and read back as the trampoline's leftover scratch (the empty args-array length, 0). Each iteration computed 0 + f().

Diagnosed via IR (byte-identical with the optimizer off — lowering and effects are correct) and assembly (the allocator parks v6 in x21; the trampoline's last write to x21 is the args length). --regalloc=stack masked the bug; direct calls were safe because compiled functions save allocator-used callee-saved registers in their own prologues — the invoker trampoline was the one generated code path missing that treatment. Same defect class as #378 on the runtime-routine side.

Fix

The invoker frame grows to hold a save area, and the prologue/epilogue now store/reload the callee-saved set the body scratches — mirroring compiled-function prologues on both targets.

Verification

  • 3 regression tests in tests/codegen/callables/expr_calls.rs (closure compound assign; callable-string/FCC with -=/*=; non-compound + property target).
  • Broad suites green: 2231 codegen (callables/closures/runtime_gc/control/types/optimizer/arrays/oop/spl) + 993 error tests.
  • Adversarially reviewed: 30/30 fixtures match PHP (10-arg spill, nested indirect calls, re-entrant trampoline, spread, register pressure, floats — the allocator's float pool d8–d14 is verified never scratched by the invoker); 92/92 emitted invokers audited in asm (8 saves + 8 reloads + single ret, no slot collisions, return registers untouched).
  • Exception path is safe by design: unwinding is setjmp/longjmp, which restores callee-saved registers from the try-entry register file, so the skipped trampoline epilogue is irrelevant — verified behaviorally across throw-through-two-trampolines, multiple live values, and partial-loop throw/catch shapes.
  • x86_64: static audit complete (the saved set is the full SysV callee-saved set besides rbp; 16-byte alignment preserved); execution coverage delegated to CI's linux-x86_64 matrix.

…sters

Fixes illegalstudio#487. The generated descriptor-invoker trampoline saved only fp/lr and
then used callee-saved registers as raw scratch (AArch64 x19-x26 via the
nested-call register and the mixed/indexed argument loaders; x86_64
r12/rbx/r13-r15). The linear register allocator relies on the platform ABI and
parks values that live across a callable_descriptor_invoke in exactly those
registers, so any such value was clobbered by the invoke: in
'$acc += $f()' inside a loop the loaded accumulator read back as the
trampoline's leftover scratch (the empty args-array length, 0) and only the
last call's value survived. Every indirect callable form was affected
(closures, arrow functions, callable strings, first-class callables, method
FCCs), with any operator, compound or not, including property targets; the
closure body itself ran every iteration. Direct calls were safe because
compiled functions save the allocator-used callee-saved registers in their own
prologues - the invoker trampoline was the one generated path missing that
treatment. --regalloc=stack masked the bug.

The invoker frame grows to hold a save area and the prologue/epilogue now
store/reload the callee-saved set it scratches, mirroring compiled-function
prologues on both targets.

Regression tests in tests/codegen/callables/expr_calls.rs (closure compound
assign, callable-string/FCC with -= and *=, non-compound + property target).
Broad suites green (2231 codegen + 993 error tests).
@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. size:xs Very small pull request. type:fix Corrects broken or incompatible behavior. labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:codegen Touches target-aware assembly or backend lowering. size:xs Very small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compound assignment with a closure/indirect call RHS accumulates only the first iteration's value

1 participant