diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 237459456b..54b30422a4 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -32,7 +32,7 @@ export function generateTerminator( isLastBlock: boolean = false, isUserFunction: boolean = false, ): Transition { - const { PUSHn, PUSH2, MSTORE, RETURN, STOP, JUMP, JUMPI } = operations; + const { PUSHn, MSTORE, RETURN, STOP } = operations; switch (term.kind) { case "return": { @@ -88,65 +88,86 @@ export function generateTerminator( // 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() - .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): State => { + let s = state as State; + 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; } case "branch": { - return pipe() - .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): State => { + let s: State = loadValue(term.condition)(state as State); + 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; } case "call": @@ -303,13 +324,13 @@ export function generateCallTerminator( 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: [], }; } diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index a23f41a672..179c6a51e7 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -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(); + 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, 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 = { ...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, @@ -340,7 +372,7 @@ export function generate( options.isUserFunction || false, func, options.functions, - )(state); + )(entry); }, stateAfterPrologue, ); diff --git a/packages/bugc/src/evmgen/recursion.test.ts b/packages/bugc/src/evmgen/recursion.test.ts new file mode 100644 index 0000000000..cf73ab8db6 --- /dev/null +++ b/packages/bugc/src/evmgen/recursion.test.ts @@ -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 { + 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); + }); + } +});