Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 78 additions & 57 deletions packages/bugc/src/evmgen/generation/control-flow/terminator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function generateTerminator<S extends Stack>(
isLastBlock: boolean = false,
isUserFunction: boolean = false,
): Transition<S, Stack> {
const { PUSHn, PUSH2, MSTORE, RETURN, STOP, JUMP, JUMPI } = operations;
const { PUSHn, MSTORE, RETURN, STOP } = operations;

switch (term.kind) {
case "return": {
Expand Down Expand Up @@ -88,65 +88,86 @@ export function generateTerminator<S extends Stack>(
// invoke discriminators. Depth stays constant: one pops,
// one pushes, on the same instruction. The function's
// terminal RETURN pops the final iteration's frame normally.
const invokeOptions = term.tailCall
? buildTailCallJumpOptions(term.tailCall)
const jumpDebug = term.tailCall
? buildTailCallJumpOptions(term.tailCall).debug
: undefined;

return pipe<S>()
.peek((state, builder) => {
const patchIndex = state.instructions.length;

return builder
.then(PUSH2([0, 0]), { as: "counter" })
.then(JUMP(invokeOptions))
.then((newState) => ({
...newState,
patches: [
...newState.patches,
{
index: patchIndex,
target: term.target,
},
],
}));
})
.done();
// Imperative, like generateCallTerminator/generateReturnEpilogue:
// drop any leftover block-local scratch so the target block is
// entered with the canonical empty stack, then jump.
return ((state: State<S>): State<Stack> => {
let s = state as State<Stack>;
while (s.stack.length > 0) {
s = {
...s,
instructions: [
...s.instructions,
{ mnemonic: "POP", opcode: 0x50 },
],
stack: s.stack.slice(1),
brands: s.brands.slice(1),
};
}

const patchIndex = s.instructions.length;
return {
...s,
instructions: [
...s.instructions,
{ mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] },
{
mnemonic: "JUMP",
opcode: 0x56,
...(jumpDebug ? { debug: jumpDebug } : {}),
},
],
patches: [...s.patches, { index: patchIndex, target: term.target }],
stack: [],
brands: [],
};
}) as Transition<S, Stack>;
}

case "branch": {
return pipe<S>()
.then(loadValue(term.condition), { as: "b" })
.peek((state, builder) => {
// Record offset for true target patch
const trueIndex = state.instructions.length;

return builder
.then(PUSH2([0, 0]), { as: "counter" })
.then(JUMPI())
.peek((state2, builder2) => {
// Record offset for false target patch
const falseIndex = state2.instructions.length;

return builder2
.then(PUSH2([0, 0]), { as: "counter" })
.then(JUMP())
.then((finalState) => ({
...finalState,
patches: [
...finalState.patches,
{
index: trueIndex,
target: term.trueTarget,
},
{
index: falseIndex,
target: term.falseTarget,
},
],
}));
});
})
.done();
// Load the condition to the top, then drop any leftover scratch
// beneath it (SWAP1/POP) so both successors are entered with the
// canonical empty stack — the JUMPI and the fall-through JUMP
// consume the condition and the pushed counters.
return ((state: State<S>): State<Stack> => {
let s: State<Stack> = loadValue(term.condition)(state as State<Stack>);
while (s.stack.length > 1) {
s = {
...s,
instructions: [
...s.instructions,
{ mnemonic: "SWAP1", opcode: 0x90 },
{ mnemonic: "POP", opcode: 0x50 },
],
stack: [s.stack[0], ...s.stack.slice(2)],
brands: [s.brands[0], ...s.brands.slice(2)],
};
}

const trueIndex = s.instructions.length;
const falseIndex = trueIndex + 2;
return {
...s,
instructions: [
...s.instructions,
{ mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] },
{ mnemonic: "JUMPI", opcode: 0x57 },
{ mnemonic: "PUSH2", opcode: 0x61, immediates: [0, 0] },
{ mnemonic: "JUMP", opcode: 0x56 },
],
patches: [
...s.patches,
{ index: trueIndex, target: term.trueTarget },
{ index: falseIndex, target: term.falseTarget },
],
stack: [],
brands: [],
};
}) as Transition<S, Stack>;
}

case "call":
Expand Down Expand Up @@ -303,13 +324,13 @@ export function generateCallTerminator<S extends Stack>(
currentState = {
...currentState,
stack: [{ id: `call_return_${funcName}`, irValue: term.dest }],
brands: ["value" as const] as unknown as Stack,
brands: ["value"],
};
} else {
currentState = {
...currentState,
stack: [],
brands: [] as unknown as Stack,
brands: [],
};
}

Expand Down
42 changes: 37 additions & 5 deletions packages/bugc/src/evmgen/generation/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,19 +319,51 @@ export function generate(
stateAfterPrologue = prologueTransition(initialState);
}

// Map each call continuation block to the block that calls into
// it. A continuation is entered at runtime with the callee's return
// value on top of the stack; every other block is entered with an
// empty stack. This is the canonical block-boundary invariant that
// lets each block's stack model be reconstructed from its role in
// the control-flow graph rather than threaded through layout order
// (which desynced the tracked stack from the runtime stack).
const callerOfContinuation = new Map<string, string>();
for (const [bid, b] of func.blocks) {
if (b.terminator.kind === "call") {
callerOfContinuation.set(b.terminator.continuation, bid);
}
}

const finalState = layout.order.reduce(
(state: State<Stack>, blockId: string, index: number) => {
const block = func.blocks.get(blockId);
if (!block) return state;

// Determine predecessor for phi resolution
// This is simplified - real implementation would track actual control flow
const predecessor = index > 0 ? layout.order[index - 1] : undefined;

// Check if this is the first or last block
const isFirstBlock = index === 0;
const isLastBlock = index === layout.order.length - 1;

// Reset the tracked stack to this block's canonical entry
// instead of inheriting the previous block's exit. A call
// continuation begins with the return value on top; any other
// block begins empty. The `predecessor` we pass through is the
// calling block for a continuation (so its return context and
// return-value spill resolve), and undefined otherwise.
const callerBlockId = callerOfContinuation.get(blockId);
let predecessor: string | undefined = undefined;
let entry: State<Stack> = { ...state, stack: [], brands: [] as Stack };
if (callerBlockId !== undefined) {
predecessor = callerBlockId;
const callTerm = func.blocks.get(callerBlockId)!.terminator;
const dest = callTerm.kind === "call" ? callTerm.dest : undefined;
if (dest) {
entry = {
...state,
stack: [{ id: `ret_${blockId}`, irValue: dest }],
brands: ["value"] as unknown as Stack,
};
}
}

return Block.generate(
block,
predecessor,
Expand All @@ -340,7 +372,7 @@ export function generate(
options.isUserFunction || false,
func,
options.functions,
)(state);
)(entry);
},
stateAfterPrologue,
);
Expand Down
145 changes: 145 additions & 0 deletions packages/bugc/src/evmgen/recursion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Recursive and branching functions must compute correctly at every
* optimization level.
*
* A call is set up by cleaning the caller's operand stack and reloading
* arguments from memory. Previously the tracked stack model was threaded
* through block layout order rather than the control-flow graph, so it
* desynced from the runtime stack: leftover scratch values were never
* accounted, the pre-call cleanup undercounted, and callees received
* corrupted arguments — every self-recursive function returned garbage.
*
* The fix establishes a canonical block-boundary stack invariant: a call
* continuation is entered with the return value on top, every other block
* is entered empty, and each block canonicalizes its stack on exit. With
* that, the tracked model matches the runtime stack and the existing
* per-instruction/terminator logic is exact.
*/
import { describe, it, expect } from "vitest";

import { executeProgram } from "#test/evm/behavioral";

type OptLevel = 0 | 1 | 2 | 3;
const LEVELS: OptLevel[] = [0, 1, 2, 3];

async function result(source: string, level: OptLevel): Promise<bigint> {
const res = await executeProgram(source, {
calldata: "",
optimizationLevel: level,
});
expect(res.callSuccess).toBe(true);
return res.getStorage(0n);
}

const sum = (body: string) => `name Sum;
define {
function sum(n: uint256, acc: uint256) -> uint256 {
if (n == 0) { return acc; } else { return sum(n - 1, acc + n); }
};
}
storage { [0] r: uint256; }
create { r = 0; }
code { ${body} }`;

// Mutual recursion through two functions, each with a branch-return.
const parity = (body: string) => `name Parity;
define {
function isEven(n: uint256) -> uint256 {
if (n == 0) { return 1; } else { return isOdd(n - 1); }
};
function isOdd(n: uint256) -> uint256 {
if (n == 0) { return 0; } else { return isEven(n - 1); }
};
}
storage { [0] r: uint256; }
create { r = 0; }
code { ${body} }`;

// Tree recursion: two recursive calls whose results combine.
const fib = (body: string) => `name Fib;
define {
function fib(n: uint256) -> uint256 {
if (n < 2) { return n; } else { return fib(n - 1) + fib(n - 2); }
};
}
storage { [0] r: uint256; }
create { r = 0; }
code { ${body} }`;

describe("recursion computes correctly at every optimization level", () => {
for (const level of LEVELS) {
it(`tail recursion accumulates (level ${level})`, async () => {
expect(await result(sum("r = sum(0, 7);"), level)).toBe(7n);
expect(await result(sum("r = sum(1, 50);"), level)).toBe(51n);
expect(await result(sum("r = sum(2, 50);"), level)).toBe(53n);
expect(await result(sum("r = sum(5, 0);"), level)).toBe(15n);
});

it(`mutual recursion (level ${level})`, async () => {
expect(await result(parity("r = isEven(6);"), level)).toBe(1n);
expect(await result(parity("r = isEven(7);"), level)).toBe(0n);
});

it(`tree recursion (level ${level})`, async () => {
expect(await result(fib("r = fib(10);"), level)).toBe(55n);
});
}
});

// Branch/merge shapes that exercise block-boundary stack cleanup
// without recursion. for-loops at O3 hit a separate, pre-existing
// block-lowering issue (tracked with the CFG-stack work) and are
// covered here only through O2.
const diamond = `name Diamond;
storage { [0] r: uint256; }
code {
let x = 0;
if (1 == 1) { x = 10; } else { x = 20; }
r = x + 1;
}`;

const forLoop = `name Loop;
storage { [0] r: uint256; }
code {
let s = 0;
for (let i = 1; i <= 5; i = i + 1) { s = s + i; }
r = s;
}`;

// A user function whose RETURN block has two predecessors (the arms
// of an if), each leaving a different amount of block-local scratch
// before the merge. This is manifestation (b) of #275: a
// multi-predecessor return block. It is guaranteed correct because
// every predecessor canonicalizes its stack to empty on exit and the
// return block is entered with the canonical empty stack, so the
// tracked model matches the runtime stack no matter which arm ran.
const mergeReturn = (arg: string) => `name MergeReturn;
define {
function f(x: uint256) -> uint256 {
let y = 0;
if (x == 0) { y = x + 1; } else { y = x + x + x + 7; }
return y;
};
}
storage { [0] r: uint256; }
create { r = 0; }
code { r = f(${arg}); }`;

describe("branch and loop control flow", () => {
for (const level of LEVELS) {
it(`diamond merge (level ${level})`, async () => {
expect(await result(diamond, level)).toBe(11n);
});

it(`multi-predecessor return block (level ${level})`, async () => {
expect(await result(mergeReturn("0"), level)).toBe(1n);
expect(await result(mergeReturn("5"), level)).toBe(22n);
});
}

for (const level of [0, 1, 2] as const) {
it(`for-loop accumulator (level ${level})`, async () => {
expect(await result(forLoop, level)).toBe(15n);
});
}
});
Loading