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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

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