From 1c255fcb859411e0735e56be4c6208c24ac98683 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Apr 2026 02:40:28 -0400 Subject: [PATCH 1/4] bugc: verify optimizer preserves invoke/return contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a behavioral test suite that compiles a set of source patterns at every optimization level (0, 1, 2, 3) and: - asserts the bytecode still runs correctly end-to-end - counts invoke/return contexts by instruction type and function identifier, then asserts the expected shape Covers every pass that could touch call sites or returns: L1: constant folding, propagation, DCE L2: CSE, TCO, jump optimization L3: block merging, return merging, R/W merging Confirms that only tail call optimization eliminates contexts (by design — the tail call becomes a jump). All other transformations preserve invoke/return contexts across levels for simple calls, nested calls, mutual recursion, non-tail self-recursion, and multi-path returns of the same value. This is groundwork for the transform context spec. --- .../src/evmgen/optimizer-contexts.test.ts | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 packages/bugc/src/evmgen/optimizer-contexts.test.ts diff --git a/packages/bugc/src/evmgen/optimizer-contexts.test.ts b/packages/bugc/src/evmgen/optimizer-contexts.test.ts new file mode 100644 index 000000000..97b031142 --- /dev/null +++ b/packages/bugc/src/evmgen/optimizer-contexts.test.ts @@ -0,0 +1,444 @@ +/** + * Verifies that invoke/return debug contexts survive + * optimizer transformations at every optimization level. + * + * Covers every pass that could touch call sites or return + * paths: + * Level 1: constant folding, propagation, DCE + * Level 2: CSE, TCO, jump optimization + * Level 3: block merging, return merging, R/W merging + * + * Each test compiles the same source at multiple levels, + * asserts the resulting bytecode still runs correctly, and + * verifies the expected invoke/return contexts are present + * with the right identifiers. Tail call optimization is + * handled separately — it intentionally eliminates the + * recursive call, so its contexts disappear by design. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import { executeProgram } from "#test/evm/behavioral"; +import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; + +const { Context } = Program; + +type OptLevel = 0 | 1 | 2 | 3; + +/** + * Compile source at the given optimization level and + * return the runtime program. + */ +async function compileAt( + source: string, + level: OptLevel, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + + if (!result.success) { + const errors = result.messages.error ?? []; + const msgs = errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"); + throw new Error(`Compilation failed at level ${level}:\n${msgs}`); + } + + return result.value.bytecode.runtimeProgram; +} + +interface CallSiteCounts { + /** Caller JUMP with invoke context, keyed by identifier. */ + invokeJump: Record; + /** Callee entry JUMPDEST with invoke context. */ + invokeJumpdest: Record; + /** Continuation JUMPDEST with return context. */ + returnJumpdest: Record; +} + +/** + * Scan a program and count invoke/return contexts by + * instruction type and function identifier. + */ +function countCallSites(program: Format.Program): CallSiteCounts { + const counts: CallSiteCounts = { + invokeJump: {}, + invokeJumpdest: {}, + returnJumpdest: {}, + }; + + for (const instr of program.instructions) { + const ctx = instr.context; + if (!ctx) continue; + + const mn = instr.operation?.mnemonic; + + if (Context.isInvoke(ctx)) { + const invoke = ctx.invoke; + const id = invoke.identifier ?? "?"; + if (mn === "JUMP") { + counts.invokeJump[id] = (counts.invokeJump[id] ?? 0) + 1; + } else if (mn === "JUMPDEST") { + counts.invokeJumpdest[id] = (counts.invokeJumpdest[id] ?? 0) + 1; + } + } else if (Context.isReturn(ctx) && mn === "JUMPDEST") { + const id = ctx.return.identifier ?? "?"; + counts.returnJumpdest[id] = (counts.returnJumpdest[id] ?? 0) + 1; + } + } + + return counts; +} + +describe("optimizer preserves invoke/return contexts", () => { + const allLevels: OptLevel[] = [0, 1, 2, 3]; + + describe("simple non-recursive call", () => { + const source = `name Simple; + +define { + function add(a: uint256, b: uint256) -> uint256 { + return a + b; + }; +} + +storage { [0] r: uint256; } +create { r = 0; } +code { r = add(10, 20); }`; + + for (const level of allLevels) { + it(`preserves contexts at level ${level}`, async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + // One caller JUMP, one callee JUMPDEST, one + // continuation JUMPDEST — all naming "add". + expect(counts.invokeJump).toEqual({ add: 1 }); + expect(counts.invokeJumpdest).toEqual({ add: 1 }); + expect(counts.returnJumpdest).toEqual({ add: 1 }); + + // Behavior is still correct. + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(30n); + }); + } + }); + + describe("constant-foldable arguments", () => { + // Exercises constant folding (level 1+): args reduce + // to constants but the call itself must remain. + const source = `name ConstFold; + +define { + function add(a: uint256, b: uint256) -> uint256 { + return a + b; + }; +} + +storage { [0] r: uint256; } +create { r = 0; } +code { r = add(2 + 3, 4 * 5); }`; + + for (const level of allLevels) { + it(`preserves call contexts at level ${level}`, async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + expect(counts.invokeJump).toEqual({ add: 1 }); + expect(counts.invokeJumpdest).toEqual({ add: 1 }); + expect(counts.returnJumpdest).toEqual({ add: 1 }); + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(25n); + }); + } + }); + + describe("multiple call sites to same function", () => { + // Exercises CSE (level 2+): dbl(5) and dbl(10) share + // no subexpressions, but verifies that CSE over the + // call setup itself doesn't collapse distinct calls. + const source = `name MultiCall; + +define { + function dbl(x: uint256) -> uint256 { + return x + x; + }; +} + +storage { [0] r: uint256; } +create { r = 0; } +code { + let a = dbl(5); + let b = dbl(10); + r = a + b; +}`; + + for (const level of allLevels) { + it(`keeps both call sites at level ${level}`, async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + expect(counts.invokeJump).toEqual({ dbl: 2 }); + expect(counts.invokeJumpdest).toEqual({ dbl: 1 }); + expect(counts.returnJumpdest).toEqual({ dbl: 2 }); + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + // dbl(5) + dbl(10) = 10 + 20 = 30 + expect(await result.getStorage(0n)).toBe(30n); + }); + } + }); + + describe("non-tail recursive call (TCO does not apply)", () => { + // factorial: n * fact(n - 1) — the multiplication + // prevents TCO from matching. Contexts for the + // recursive call should survive all levels. + const source = `name NonTailRec; + +define { + function fact(n: uint256) -> uint256 { + if (n < 2) { return 1; } + else { return n * fact(n - 1); } + }; +} + +storage { [0] r: uint256; } +create { r = 0; } +code { r = fact(5); }`; + + for (const level of allLevels) { + it(`preserves recursive call contexts at level ${level}`, async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + // Two caller JUMPs (main -> fact, fact -> fact) + // and two continuation JUMPDESTs for them. + expect(counts.invokeJump).toEqual({ fact: 2 }); + expect(counts.invokeJumpdest).toEqual({ fact: 1 }); + expect(counts.returnJumpdest).toEqual({ fact: 2 }); + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(120n); + }); + } + }); + + describe("mutual recursion", () => { + const source = `name Mutual; + +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 { r = isEven(4); }`; + + for (const level of allLevels) { + it(`preserves both functions' contexts at level ${level}`, async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + // isEven called from main and from isOdd. + // isOdd called from isEven. + // Each function has one callee entry JUMPDEST. + expect(counts.invokeJump).toEqual({ + isEven: 2, + isOdd: 1, + }); + expect(counts.invokeJumpdest).toEqual({ + isEven: 1, + isOdd: 1, + }); + expect(counts.returnJumpdest).toEqual({ + isEven: 2, + isOdd: 1, + }); + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(1n); + }); + } + }); + + describe("nested calls (one function calls another)", () => { + const source = `name Nested; + +define { + function add(a: uint256, b: uint256) -> uint256 { + return a + b; + }; + function addThree(x: uint256, y: uint256, z: uint256) -> uint256 { + let s1 = add(x, y); + let s2 = add(s1, z); + return s2; + }; +} + +storage { [0] r: uint256; } +create { r = 0; } +code { r = addThree(1, 2, 3); }`; + + for (const level of allLevels) { + it(`preserves nested call contexts at level ${level}`, async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + expect(counts.invokeJump).toEqual({ + addThree: 1, + add: 2, + }); + expect(counts.invokeJumpdest).toEqual({ + addThree: 1, + add: 1, + }); + expect(counts.returnJumpdest).toEqual({ + addThree: 1, + add: 2, + }); + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(6n); + }); + } + }); + + describe("multiple returns of same constant (return merging)", () => { + // Triggers return-merging at level 3: two `return 42` + // blocks collapse into one. Only one return context + // survives in bytecode, but it must still be present + // and identify the right function. + const source = `name ReturnMerge; + +define { + function check(a: uint256, b: uint256) -> uint256 { + if (a == 0) { return 42; } + if (b == 0) { return 42; } + return a + b; + }; +} + +storage { [0] r: uint256; } +create { r = 0; } +code { r = check(3, 4); }`; + + for (const level of allLevels) { + it(`preserves check call contexts at level ${level}`, async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + expect(counts.invokeJump).toEqual({ check: 1 }); + expect(counts.invokeJumpdest).toEqual({ check: 1 }); + expect(counts.returnJumpdest).toEqual({ check: 1 }); + + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(7n); + }); + } + }); + + describe("tail call optimization (TCO eliminates recursive call)", () => { + // `count` is tail-recursive: the recursive call is in + // return position. At levels 2 and 3, TCO rewrites the + // recursive call into a jump, so its invoke/return + // contexts are intentionally eliminated. + // + // This test documents the expected behavior: the initial + // call from `code` to `count` still has contexts, but + // the recursive self-call does not. The helper `succ` + // is called each iteration and keeps its contexts. + const source = `name TailCall; + +define { + function succ(n: uint256) -> uint256 { + return n + 1; + }; + function count(n: uint256, target: uint256) -> uint256 { + if (n < target) { return count(succ(n), target); } + else { return n; } + }; +} + +storage { [0] r: uint256; } +create { r = 0; } +code { r = count(0, 5); }`; + + it("keeps both call contexts at level 1 (no TCO)", async () => { + const program = await compileAt(source, 1); + const counts = countCallSites(program); + + // Initial count call from main, plus the recursive + // self-call. succ is still a separate function call. + expect(counts.invokeJump).toEqual({ count: 2, succ: 1 }); + expect(counts.invokeJumpdest).toEqual({ count: 1, succ: 1 }); + expect(counts.returnJumpdest).toEqual({ count: 2, succ: 1 }); + }); + + for (const level of [2, 3] as const) { + it( + `eliminates recursive count call but preserves ` + + `initial call and succ at level ${level}`, + async () => { + const program = await compileAt(source, level); + const counts = countCallSites(program); + + // Only the initial (non-tail) count call remains. + // The recursive self-call became a block-internal + // jump. `succ` still has its call/return contexts + // — it's called each loop iteration. + expect(counts.invokeJump).toEqual({ count: 1, succ: 1 }); + expect(counts.invokeJumpdest).toEqual({ count: 1, succ: 1 }); + expect(counts.returnJumpdest).toEqual({ count: 1, succ: 1 }); + + // Still correct end-to-end. + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(0n)).toBe(5n); + }, + ); + } + }); +}); From 072659ac57df28877e7ab93fef1070a5e07837c9 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Apr 2026 02:55:17 -0400 Subject: [PATCH 2/4] bugc: preserve invoke context through tail call optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TCO replaces a tail-recursive call terminator with a jump to the function's loop header. Previously this dropped the invoke debug context, so the recursive call became invisible to debuggers — a deeply recursive program looked like one giant loop with no logical call stack. Now the TCO pass records a TailCall metadata block on the replacement jump terminator, and codegen attaches an invoke debug context to the generated JUMP. The context mirrors the normal caller-JUMP invoke: identity + declaration + code target, no argument pointers. patchInvokeTarget resolves the placeholder code offset from the function registry the same way it does for regular calls. No matching return context is emitted for the TCO'd call — the tail call folds into the outer activation's return, and a future transform: tailcall marker will let the debugger reconcile the missing return when the outer function eventually returns and pops all accumulated tail frames at once. Updates the optimizer-contexts test suite to assert the preserved invoke is present at levels 2 and 3, and that the return context intentionally does not duplicate. --- .../generation/control-flow/terminator.ts | 53 ++++++++++++++++++- .../src/evmgen/optimizer-contexts.test.ts | 48 +++++++++++------ packages/bugc/src/ir/spec/block.ts | 27 +++++++++- .../optimizer/steps/tail-call-optimization.ts | 13 +++++ 4 files changed, 122 insertions(+), 19 deletions(-) diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index bc0ba0131..8e8b76e5a 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -67,13 +67,26 @@ export function generateTerminator( } case "jump": { + // When this jump replaces a tail-recursive call (TCO), + // attach an invoke debug context to the JUMP so the + // debugger can still see the recursive call in the + // trace. The target code pointer uses placeholder + // offset 0; patchInvokeTarget resolves it later from + // the function registry. No matching return context + // is emitted — the tail call folds into the outer + // activation's return, per the transform: tailcall + // convention. + const invokeOptions = term.tailCall + ? buildTailCallJumpOptions(term.tailCall) + : undefined; + return pipe() .peek((state, builder) => { const patchIndex = state.instructions.length; return builder .then(PUSH2([0, 0]), { as: "counter" }) - .then(JUMP()) + .then(JUMP(invokeOptions)) .then((newState) => ({ ...newState, patches: [ @@ -398,6 +411,44 @@ function generateReturnEpilogue( }) as Transition; } +/** + * Build JUMP instruction options carrying an invoke debug + * context for a TCO-replaced tail call. + * + * Mirrors the caller-JUMP invoke emitted by the normal call + * terminator: identity + declaration + code target, no + * argument pointers. The target uses placeholder offset 0 + * and is resolved later by patchInvokeTarget. + */ +function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): { + debug: { context: Format.Program.Context }; +} { + const declaration = + tailCall.declarationLoc && tailCall.declarationSourceId + ? { + source: { id: tailCall.declarationSourceId }, + range: tailCall.declarationLoc, + } + : undefined; + + const invoke: Format.Program.Context.Invoke = { + invoke: { + jump: true as const, + identifier: tailCall.function, + ...(declaration ? { declaration } : {}), + target: { + pointer: { + location: "code" as const, + offset: 0, + length: 1, + }, + }, + }, + }; + + return { debug: { context: invoke as Format.Program.Context } }; +} + /** PUSH an integer as the smallest PUSHn. */ function pushImm(value: number, debug: Ir.Block.Debug): Evm.Instruction[] { if (value === 0) { diff --git a/packages/bugc/src/evmgen/optimizer-contexts.test.ts b/packages/bugc/src/evmgen/optimizer-contexts.test.ts index 97b031142..b3a4f2fbc 100644 --- a/packages/bugc/src/evmgen/optimizer-contexts.test.ts +++ b/packages/bugc/src/evmgen/optimizer-contexts.test.ts @@ -11,9 +11,11 @@ * Each test compiles the same source at multiple levels, * asserts the resulting bytecode still runs correctly, and * verifies the expected invoke/return contexts are present - * with the right identifiers. Tail call optimization is - * handled separately — it intentionally eliminates the - * recursive call, so its contexts disappear by design. + * with the right identifiers. TCO is a special case: the + * invoke context is preserved on the jump that replaces the + * recursive call, but no matching return context is emitted + * because the tail call folds into the outer activation's + * return. */ import { describe, it, expect } from "vitest"; @@ -377,16 +379,18 @@ code { r = check(3, 4); }`; } }); - describe("tail call optimization (TCO eliminates recursive call)", () => { + describe("tail call optimization preserves invoke contexts", () => { // `count` is tail-recursive: the recursive call is in // return position. At levels 2 and 3, TCO rewrites the - // recursive call into a jump, so its invoke/return - // contexts are intentionally eliminated. + // recursive call into a jump, but the invoke context + // must still be emitted on that jump so debuggers can + // see "this was a recursive call" in the trace. // - // This test documents the expected behavior: the initial - // call from `code` to `count` still has contexts, but - // the recursive self-call does not. The helper `succ` - // is called each iteration and keeps its contexts. + // No return context is emitted for the TCO'd call — + // the tail call folds into the outer activation's + // return. A future `transform: tailcall` marker will + // let the debugger reconcile the missing return with + // the eventual outer return popping all tail frames. const source = `name TailCall; define { @@ -416,18 +420,28 @@ code { r = count(0, 5); }`; for (const level of [2, 3] as const) { it( - `eliminates recursive count call but preserves ` + - `initial call and succ at level ${level}`, + `preserves invoke on TCO'd jump but drops its ` + + `return context at level ${level}`, async () => { const program = await compileAt(source, level); const counts = countCallSites(program); - // Only the initial (non-tail) count call remains. - // The recursive self-call became a block-internal - // jump. `succ` still has its call/return contexts - // — it's called each loop iteration. - expect(counts.invokeJump).toEqual({ count: 1, succ: 1 }); + // Both count invokes are still present: the + // initial call JUMP and the TCO'd recursive + // JUMP (which targets the loop header). `succ` + // keeps its call/return contexts since it's + // invoked each iteration. + expect(counts.invokeJump).toEqual({ count: 2, succ: 1 }); + + // Only one callee-entry JUMPDEST per function: + // count's is shared between first entry (from + // the TCO trampoline) and subsequent iterations + // (from the TCO'd jump). expect(counts.invokeJumpdest).toEqual({ count: 1, succ: 1 }); + + // Only the initial count call has a continuation + // JUMPDEST; the TCO'd call has no return because + // it folds into the outer activation's return. expect(counts.returnJumpdest).toEqual({ count: 1, succ: 1 }); // Still correct end-to-end. diff --git a/packages/bugc/src/ir/spec/block.ts b/packages/bugc/src/ir/spec/block.ts index e1f72aa9f..182bc35f8 100644 --- a/packages/bugc/src/ir/spec/block.ts +++ b/packages/bugc/src/ir/spec/block.ts @@ -1,4 +1,5 @@ import type * as Format from "@ethdebug/format"; +import type * as Ast from "#ast"; import { Value } from "./value.js"; import type { Type } from "./type.js"; @@ -30,11 +31,35 @@ export namespace Block { context?: Format.Program.Context; } + /** + * Metadata for a jump that originated as a tail call. + * + * TCO replaces a call terminator with a jump to the + * function's loop header. This metadata preserves the + * logical "invoke" identity so codegen can emit an + * invoke debug context on the JUMP, letting debuggers + * still see the recursive call in the trace. + */ + export interface TailCall { + /** Name of the recursively-called function */ + function: string; + /** Source location of the function declaration */ + declarationLoc?: Ast.SourceLocation; + /** Source ID for the declaration (inherited from module) */ + declarationSourceId?: string; + } + /** * Block terminator instructions */ export type Terminator = - | { kind: "jump"; target: string; operationDebug: Block.Debug } + | { + kind: "jump"; + target: string; + operationDebug: Block.Debug; + /** Set when this jump replaces a tail-recursive call */ + tailCall?: TailCall; + } | { kind: "branch"; condition: Value; diff --git a/packages/bugc/src/optimizer/steps/tail-call-optimization.ts b/packages/bugc/src/optimizer/steps/tail-call-optimization.ts index a5273a19a..251cd78ea 100644 --- a/packages/bugc/src/optimizer/steps/tail-call-optimization.ts +++ b/packages/bugc/src/optimizer/steps/tail-call-optimization.ts @@ -146,10 +146,23 @@ export class TailCallOptimizationStep extends BaseOptimizationStep { } } + // Preserve the logical "invoke" identity of the + // recursive call. Codegen uses this to attach an + // invoke debug context to the TCO JUMP so the + // debugger can still see the recursive call in + // the trace, even though the implementation is + // now a block-internal jump. + const tailCall: Ir.Block.TailCall = { + function: funcName, + ...(func.loc ? { declarationLoc: func.loc } : {}), + ...(func.sourceId ? { declarationSourceId: func.sourceId } : {}), + }; + block.terminator = { kind: "jump", target: origEntryId, operationDebug: callTerm.operationDebug, + tailCall, }; context.trackTransformation({ From f14591a193d5c9098b94bea1a07470ca0334d40c Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Apr 2026 03:02:57 -0400 Subject: [PATCH 3/4] bugc: pair invoke with return on TCO back-edge JUMP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the TCO debug-context fix: the back-edge JUMP now carries a gather context with BOTH the previous iteration's return and the new iteration's invoke. Depth stays constant across the JUMP — one frame pops, one pushes, on the same instruction. The function's terminal RETURN then pops the final iteration's frame normally. This models source-level semantics rather than the optimized control flow: the debugger's logical call stack matches what the programmer wrote, and transform: tailcall markers (future work) can annotate these JUMPs as TCO-produced. Also fixes patchInvokeTarget to walk into gather contexts so the invoke leaf's placeholder code offset gets resolved from the function registry. Test helper countCallSites updated to unwrap gather contexts and count (invoke, return) pairs on JUMPs separately from the traditional JUMPDEST buckets. --- .../generation/control-flow/terminator.ts | 59 ++++++--- .../bugc/src/evmgen/generation/function.ts | 17 ++- .../src/evmgen/optimizer-contexts.test.ts | 125 +++++++++++++----- 3 files changed, 151 insertions(+), 50 deletions(-) diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 8e8b76e5a..32bf758a6 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -68,14 +68,11 @@ export function generateTerminator( case "jump": { // When this jump replaces a tail-recursive call (TCO), - // attach an invoke debug context to the JUMP so the - // debugger can still see the recursive call in the - // trace. The target code pointer uses placeholder - // offset 0; patchInvokeTarget resolves it later from - // the function registry. No matching return context - // is emitted — the tail call folds into the outer - // activation's return, per the transform: tailcall - // convention. + // attach a gather context to the JUMP combining the + // previous iteration's return and the new iteration's + // invoke. 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) : undefined; @@ -412,13 +409,28 @@ function generateReturnEpilogue( } /** - * Build JUMP instruction options carrying an invoke debug - * context for a TCO-replaced tail call. + * Build JUMP instruction options for a TCO-replaced tail call. * - * Mirrors the caller-JUMP invoke emitted by the normal call - * terminator: identity + declaration + code target, no - * argument pointers. The target uses placeholder offset 0 - * and is resolved later by patchInvokeTarget. + * The JUMP carries BOTH contexts in a gather: + * - return: the previous iteration's return + * - invoke: the new iteration's call + * + * Semantically the debugger sees frame depth stay constant + * across the back-edge JUMP: the previous frame pops, the + * new one pushes, on the same instruction. The function's + * terminal RETURN (elsewhere) emits a return context + * normally, popping the final iteration's frame. + * + * The invoke mirrors the normal caller-JUMP invoke + * (identity + declaration + code target, no argument + * pointers). The return uses stack slot 0 as a placeholder + * pointer — TCO does not materialize the intermediate + * return value, so this is best-effort; a future + * `transform: tailcall` marker will let debuggers + * special-case it. + * + * The invoke target uses placeholder offset 0 and is + * resolved later by patchInvokeTarget. */ function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): { debug: { context: Format.Program.Context }; @@ -431,6 +443,19 @@ function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): { } : undefined; + const returnCtx: Format.Program.Context.Return = { + return: { + identifier: tailCall.function, + ...(declaration ? { declaration } : {}), + data: { + pointer: { + location: "stack" as const, + slot: 0, + }, + }, + }, + }; + const invoke: Format.Program.Context.Invoke = { invoke: { jump: true as const, @@ -446,7 +471,11 @@ function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): { }, }; - return { debug: { context: invoke as Format.Program.Context } }; + const gather: Format.Program.Context.Gather = { + gather: [returnCtx, invoke], + }; + + return { debug: { context: gather as Format.Program.Context } }; } /** PUSH an integer as the smallest PUSHn. */ diff --git a/packages/bugc/src/evmgen/generation/function.ts b/packages/bugc/src/evmgen/generation/function.ts index 759c30aec..5b1944b1e 100644 --- a/packages/bugc/src/evmgen/generation/function.ts +++ b/packages/bugc/src/evmgen/generation/function.ts @@ -501,7 +501,9 @@ export function patchFunctionCalls( * Resolve placeholder code pointer offsets in invoke debug * contexts. The codegen emits `{ location: "code", offset: 0 }` * as a placeholder; this replaces offset with the actual - * function entry address from the registry. + * function entry address from the registry. Walks into + * gather contexts so TCO back-edge JUMPs (which pair an + * invoke with a return) are patched too. */ function patchInvokeTarget( inst: Evm.Instruction, @@ -509,6 +511,19 @@ function patchInvokeTarget( ): void { const ctx = inst.debug?.context; if (!ctx) return; + patchInvokeInContext(ctx, functionRegistry); +} + +function patchInvokeInContext( + ctx: Format.Program.Context, + functionRegistry: Record, +): void { + if (Format.Program.Context.isGather(ctx)) { + for (const sub of ctx.gather) { + patchInvokeInContext(sub, functionRegistry); + } + return; + } if (!Format.Program.Context.isInvoke(ctx)) return; diff --git a/packages/bugc/src/evmgen/optimizer-contexts.test.ts b/packages/bugc/src/evmgen/optimizer-contexts.test.ts index b3a4f2fbc..4fe92325f 100644 --- a/packages/bugc/src/evmgen/optimizer-contexts.test.ts +++ b/packages/bugc/src/evmgen/optimizer-contexts.test.ts @@ -12,10 +12,10 @@ * asserts the resulting bytecode still runs correctly, and * verifies the expected invoke/return contexts are present * with the right identifiers. TCO is a special case: the - * invoke context is preserved on the jump that replaces the - * recursive call, but no matching return context is emitted - * because the tail call folds into the outer activation's - * return. + * back-edge JUMP that replaces the recursive call carries a + * gather context with BOTH the previous iteration's return + * and the new iteration's invoke, so frame depth stays + * constant across the optimization. */ import { describe, it, expect } from "vitest"; @@ -25,6 +25,7 @@ import type * as Format from "@ethdebug/format"; import { Program } from "@ethdebug/format"; const { Context } = Program; +const { Invocation } = Context.Invoke; type OptLevel = 0 | 1 | 2 | 3; @@ -60,17 +61,37 @@ interface CallSiteCounts { invokeJumpdest: Record; /** Continuation JUMPDEST with return context. */ returnJumpdest: Record; + /** + * JUMP carrying a return context (TCO back-edge, where + * the previous iteration's return is paired with the new + * iteration's invoke in a gather). + */ + returnJump: Record; +} + +/** + * Flatten a context into its direct invoke/return leaves, + * unwrapping any enclosing gather. + */ +function unwrapLeaves(ctx: Format.Program.Context): Format.Program.Context[] { + if (Context.isGather(ctx)) { + return ctx.gather.flatMap(unwrapLeaves); + } + return [ctx]; } /** * Scan a program and count invoke/return contexts by - * instruction type and function identifier. + * instruction type and function identifier. Handles gather + * contexts so TCO's (return + invoke) JUMPs get counted in + * both the invokeJump and returnJump buckets. */ function countCallSites(program: Format.Program): CallSiteCounts { const counts: CallSiteCounts = { invokeJump: {}, invokeJumpdest: {}, returnJumpdest: {}, + returnJump: {}, }; for (const instr of program.instructions) { @@ -79,17 +100,22 @@ function countCallSites(program: Format.Program): CallSiteCounts { const mn = instr.operation?.mnemonic; - if (Context.isInvoke(ctx)) { - const invoke = ctx.invoke; - const id = invoke.identifier ?? "?"; - if (mn === "JUMP") { - counts.invokeJump[id] = (counts.invokeJump[id] ?? 0) + 1; - } else if (mn === "JUMPDEST") { - counts.invokeJumpdest[id] = (counts.invokeJumpdest[id] ?? 0) + 1; + for (const leaf of unwrapLeaves(ctx)) { + if (Context.isInvoke(leaf)) { + const id = leaf.invoke.identifier ?? "?"; + if (mn === "JUMP") { + counts.invokeJump[id] = (counts.invokeJump[id] ?? 0) + 1; + } else if (mn === "JUMPDEST") { + counts.invokeJumpdest[id] = (counts.invokeJumpdest[id] ?? 0) + 1; + } + } else if (Context.isReturn(leaf)) { + const id = leaf.return.identifier ?? "?"; + if (mn === "JUMPDEST") { + counts.returnJumpdest[id] = (counts.returnJumpdest[id] ?? 0) + 1; + } else if (mn === "JUMP") { + counts.returnJump[id] = (counts.returnJump[id] ?? 0) + 1; + } } - } else if (Context.isReturn(ctx) && mn === "JUMPDEST") { - const id = ctx.return.identifier ?? "?"; - counts.returnJumpdest[id] = (counts.returnJumpdest[id] ?? 0) + 1; } } @@ -379,18 +405,17 @@ code { r = check(3, 4); }`; } }); - describe("tail call optimization preserves invoke contexts", () => { + describe("tail call optimization preserves invoke and return", () => { // `count` is tail-recursive: the recursive call is in // return position. At levels 2 and 3, TCO rewrites the - // recursive call into a jump, but the invoke context - // must still be emitted on that jump so debuggers can - // see "this was a recursive call" in the trace. + // recursive call into a back-edge JUMP. That JUMP + // carries a gather context with BOTH: + // - return: previous iteration's return + // - invoke: new iteration's call // - // No return context is emitted for the TCO'd call — - // the tail call folds into the outer activation's - // return. A future `transform: tailcall` marker will - // let the debugger reconcile the missing return with - // the eventual outer return popping all tail frames. + // Depth stays constant across the JUMP — one frame pops, + // one pushes. The function's terminal RETURN emits a + // return context normally, popping the final frame. const source = `name TailCall; define { @@ -416,34 +441,66 @@ code { r = count(0, 5); }`; expect(counts.invokeJump).toEqual({ count: 2, succ: 1 }); expect(counts.invokeJumpdest).toEqual({ count: 1, succ: 1 }); expect(counts.returnJumpdest).toEqual({ count: 2, succ: 1 }); + // At level 1 there are no TCO back-edge JUMPs. + expect(counts.returnJump).toEqual({}); }); for (const level of [2, 3] as const) { it( - `preserves invoke on TCO'd jump but drops its ` + - `return context at level ${level}`, + `preserves invoke and return on TCO back-edge ` + + `JUMP at level ${level}`, async () => { const program = await compileAt(source, level); const counts = countCallSites(program); // Both count invokes are still present: the - // initial call JUMP and the TCO'd recursive - // JUMP (which targets the loop header). `succ` - // keeps its call/return contexts since it's - // invoked each iteration. + // initial call JUMP and the TCO'd back-edge JUMP. + // `succ` keeps its call/return contexts since + // it's invoked each iteration. expect(counts.invokeJump).toEqual({ count: 2, succ: 1 }); // Only one callee-entry JUMPDEST per function: // count's is shared between first entry (from // the TCO trampoline) and subsequent iterations - // (from the TCO'd jump). + // (from the TCO'd JUMP). expect(counts.invokeJumpdest).toEqual({ count: 1, succ: 1 }); - // Only the initial count call has a continuation - // JUMPDEST; the TCO'd call has no return because - // it folds into the outer activation's return. + // The initial count call's continuation JUMPDEST + // and succ's continuation JUMPDEST both carry + // return contexts as usual. expect(counts.returnJumpdest).toEqual({ count: 1, succ: 1 }); + // The TCO back-edge JUMP additionally carries a + // return context for `count` (the previous + // iteration's return), paired with its invoke in + // a gather. This keeps the debugger's logical + // frame depth constant across the back-edge. + expect(counts.returnJump).toEqual({ count: 1 }); + + // The invoke target inside the gather must be + // patched to the actual count entry, not left as + // the placeholder offset 0. This guards against + // patchInvokeTarget failing to walk into gather. + const tcoJump = program.instructions.find( + (instr) => + instr.operation?.mnemonic === "JUMP" && + instr.context !== undefined && + Context.isGather(instr.context), + ); + expect(tcoJump).toBeDefined(); + const gather = tcoJump!.context as Format.Program.Context.Gather; + const invokeLeaf = gather.gather.find(Context.isInvoke); + expect(invokeLeaf).toBeDefined(); + const invocation = invokeLeaf!.invoke; + expect(Invocation.isInternalCall(invocation)).toBe(true); + const internalCall = + invocation as Format.Program.Context.Invoke.Invocation.InternalCall; + const invokeTarget = internalCall.target.pointer; + expect(invokeTarget).toBeDefined(); + expect( + "offset" in invokeTarget ? invokeTarget.offset : undefined, + ).not.toBe(0); + // Still correct end-to-end. const result = await executeProgram(source, { calldata: "", From 4b621fe84774eb461d7999aa2894c7ebfd20d8c0 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Thu, 16 Apr 2026 03:22:37 -0400 Subject: [PATCH 4/4] bugc: drop placeholder return.data at TCO back-edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the format change in #211 making `return.data` optional, the TCO back-edge JUMP now emits a bare return context (identifier + declaration only). The stack-slot-0 placeholder was semantically wrong anyway — that slot holds the new iteration's first argument, not the previous iteration's return value. TCO doesn't materialize the intermediate return value at all; the actual return happens at the function's terminal RETURN. --- .../evmgen/generation/control-flow/terminator.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 32bf758a6..0bb5b5939 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -423,11 +423,9 @@ function generateReturnEpilogue( * * The invoke mirrors the normal caller-JUMP invoke * (identity + declaration + code target, no argument - * pointers). The return uses stack slot 0 as a placeholder - * pointer — TCO does not materialize the intermediate - * return value, so this is best-effort; a future - * `transform: tailcall` marker will let debuggers - * special-case it. + * pointers). The return omits `data` because TCO does not + * materialize the intermediate return value — the actual + * return happens later at the function's terminal RETURN. * * The invoke target uses placeholder offset 0 and is * resolved later by patchInvokeTarget. @@ -447,12 +445,6 @@ function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): { return: { identifier: tailCall.function, ...(declaration ? { declaration } : {}), - data: { - pointer: { - location: "stack" as const, - slot: 0, - }, - }, }, };