Skip to content

bugc: list the caller's locals at the entry of inlined code as at the call - #380

Merged
gnidan merged 4 commits into
mainfrom
bugc-pointer-gap
Oct 15, 2026
Merged

gnidan merged 4 commits into
mainfrom
bugc-pointer-gap

Conversation

@gnidan

@gnidan gnidan commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

At -O 2 and up, the JUMPDEST where an inlined function's code starts listed the caller's locals with no pointer, for that one step, though their bytes had not moved. The jump before it and the steps after the return both had the pointers. For example, a contract that copies a struct from storage to a let player: Player in memory, then calls an inlined _rolledHit(), gives player no pointer at that JUMPDEST only.

The cause is in local-variable tracking. A block's entry is placed just before its first instruction. At the start of inlined code, that instruction carries the invoke, so the entry lists the caller's locals. But the position used to decide which version of each local is current was that instruction's origin, which is in the callee. The caller's versions have no place there, so tracking fell back to the position after optimization, where a local with a version the optimizer removed gets no pointer.

The fix: inlining now records where the call was before optimization (callOrigin) on the op that carries the invoke, and the entry is placed there. The call's own position is the caller's state as the call begins (a call's result is defined in its continuation), so the entry lists what the call itself lists. This holds whatever jump optimization does later: the jump into inlined code can be a branch, a jump from a block where two branches meet, or the jump that returns from another inlined function, and none of these carries the call's position. Where other passes combine an instruction's debug (folding, CSE, read/write merging, nested inlining), the result keeps the callOrigin of the debug whose invoke comes first. Where a pass moves invokes onto a neighbour (dropInstructions, jump optimization, block merging, read/write merging; #384), the call origin of the first one goes with it, as returned goes with a return. If the moved invokes split between the op before and the op after, the invokes after get no call origin, so their entry falls back to listing without the call's position (which can leave a pointer out, but never points at a stale value).

The tests walk the trace. At O2 and O3, for a call straight on, on one branch, after two branches meet, with a constant argument (so the callee's first instruction folds away), and across a call that an inlined function inlines, the step before the invoke is a JUMPDEST that lists the struct local with a pointer, and the soundness check reads the right value through it. Where O3 block merging leaves no JUMPDEST between the caller's code and the inlined code (as for the nested call), that step is skipped. A local given a constant between two inlined calls has no pointer at the second call's JUMPDEST: its value there is the constant, which has no place, and its old place still holds the old value. This is checked at O2 with the second call right after the first, and at O2 and O3 with the second call on a branch (at O3 the first shape has no JUMPDEST there). These tests fail on main. Unit tests check that placeActivations, dropInstructions and combineDebugContexts carry callOrigin with an invoke, including the split, and an IR test checks it where CSE drops an inlined body's first instruction.

Over all examples and the two blog contracts, at O0 to O3, bytecode is identical to main, and the output changes only at JUMPDESTs that start inlined code. Most now list the same variables as the jump before them. In functions.bug, the entry of the second inlined add also locates sum1, the first call's result, which the jump returning from the first call leaves without a pointer; the value read through it is right.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-15 16:32 UTC

@gnidan gnidan changed the title bugc: keep the caller's locals located at the entry of inlined code bugc: list the caller's locals at the entry of inlined code as at the call Oct 13, 2026
… call

Inlining records where the call was before optimization (callOrigin) on
the op that carries the invoke; the entry of the inlined code is placed
there, so the caller's locals are listed as the call lists them.
withPlacement keeps callOrigin where debugs are combined.
When a pass moves an op's invoke onto a neighbour (dropInstructions,
jump optimization, block merging, read/write merging), the call origin
of its outermost invoke goes with it, as returned goes with a return.
Inlining uses Ir.Utils.withoutActivationsDebug.
placeActivations takes the first invoke's call origin; where the
invokes split between two ops, those after get none. Combining debugs
keeps the call origin of the debug whose invoke comes first. Test (m)
at O3 with the second call on a branch.
withoutActivationsDebug now drops callOrigin too, so no debug keeps a
call origin with no invoke for a combine to pair with another's.
@gnidan
gnidan merged commit dffaa94 into main Oct 15, 2026
8 checks passed
@gnidan
gnidan deleted the bugc-pointer-gap branch October 15, 2026 16:28
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.

1 participant