diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index fffc065d2..ab203771c 100644 --- a/packages/bugc/src/evmgen/generation/block.ts +++ b/packages/bugc/src/evmgen/generation/block.ts @@ -14,6 +14,7 @@ import { Memory } from "#evmgen/analysis"; import { calculateSize } from "#evmgen/serialize"; import * as Instruction from "./instruction.js"; +import { bracketActivation, carriesActivation } from "./bracket-activation.js"; import { loadValue } from "./values/index.js"; import { generateTerminator, @@ -161,9 +162,31 @@ export function generate( // the runtime predecessor differs from the layout-order // predecessor. - // Process regular instructions + // Process regular instructions. Invoke/return activation + // discriminators must be bracketed to the first/last emitted op + // of the instruction (see bracket-activation.ts); everything else + // (source mapping, variables, transform markers) rides all ops. for (const inst of block.instructions) { - result = result.then(Instruction.generate(inst)); + const gen = Instruction.generate(inst); + const operationCtx = inst.operationDebug?.context; + if ( + !carriesActivation(operationCtx, "invoke") && + !carriesActivation(operationCtx, "return") + ) { + result = result.then(gen); + continue; + } + result = result.peek((state, builder) => { + const start = state.instructions.length; + return builder.then(gen).then((s) => ({ + ...s, + instructions: bracketActivation( + s.instructions, + start, + operationCtx, + ), + })); + }); } // Emit phi copies for successor blocks before the diff --git a/packages/bugc/src/evmgen/generation/bracket-activation.ts b/packages/bugc/src/evmgen/generation/bracket-activation.ts new file mode 100644 index 000000000..653c2ffc8 --- /dev/null +++ b/packages/bugc/src/evmgen/generation/bracket-activation.ts @@ -0,0 +1,164 @@ +/** + * Bracket invoke/return activation discriminators onto the boundary + * ops of an IR instruction's emitted op-run. + * + * A single IR instruction lowers to N EVM micro-ops, and the generic + * lowering attaches that instruction's whole `operationDebug` (source + * mapping, variables, transform markers, AND any invoke/return + * discriminators) to every one of those ops. That is correct for + * source/variable/transform context — a debugger wants all N ops + * mapped to the instruction — but WRONG for invoke/return: those are + * positional activation boundaries. An `invoke` marks a single push + * point; a `return` a single pop point. Broadcasting them across the + * whole op-run makes a push/pop reconstruction see every op as both a + * push and a pop. + * + * This module de-smears: for the ops emitted by one instruction, the + * `invoke` discriminator is kept on only the FIRST op, `return` on only + * the LAST op, and stripped from the interior. The `transform` + * membership markers (and source/variables) stay on every op. + * + * It is a general evmgen invariant, not inline-specific: it is a no-op + * for real calls (whose invoke/return already ride single-op JUMP / + * JUMPDEST terminators) and fires only when invoke/return happen to + * ride a multi-op instruction — which today is inlined virtual + * activations. + */ +import type * as Format from "@ethdebug/format"; +import type * as Evm from "#evm"; + +type Ctx = Format.Program.Context; +type Activation = "invoke" | "return"; + +function isPick(ctx: Ctx): ctx is Ctx & { pick: Ctx[] } { + return ( + typeof ctx === "object" && + ctx !== null && + "pick" in ctx && + Array.isArray((ctx as { pick: unknown }).pick) + ); +} + +function isGather(ctx: Ctx): ctx is Ctx & { gather: Ctx[] } { + return ( + typeof ctx === "object" && + ctx !== null && + "gather" in ctx && + Array.isArray((ctx as { gather: unknown }).gather) + ); +} + +/** Whether ctx carries the given activation key anywhere, reaching + * into pick/gather composites. */ +export function carriesActivation( + ctx: Ctx | undefined, + key: Activation, +): boolean { + if (!ctx || typeof ctx !== "object") return false; + if (isPick(ctx)) return ctx.pick.some((c) => carriesActivation(c, key)); + if (isGather(ctx)) return ctx.gather.some((c) => carriesActivation(c, key)); + return key in ctx; +} + +/** The first activation value found for the given key, reaching into + * pick/gather composites. */ +function findActivation(ctx: Ctx | undefined, key: Activation): unknown { + if (!ctx || typeof ctx !== "object") return undefined; + if (isPick(ctx)) { + for (const c of ctx.pick) { + const v = findActivation(c, key); + if (v !== undefined) return v; + } + return undefined; + } + if (isGather(ctx)) { + for (const c of ctx.gather) { + const v = findActivation(c, key); + if (v !== undefined) return v; + } + return undefined; + } + return (ctx as Record)[key]; +} + +/** Remove invoke and return discriminators anywhere in ctx, reaching + * into pick/gather composites. Returns undefined if nothing remains. */ +export function stripActivation(ctx: Ctx | undefined): Ctx | undefined { + if (!ctx || typeof ctx !== "object") return ctx; + if (isPick(ctx)) { + const kids = ctx.pick + .map(stripActivation) + .filter((c): c is Ctx => c !== undefined); + if (kids.length === 0) return undefined; + if (kids.length === 1) return kids[0]; + return { pick: kids } as Ctx; + } + if (isGather(ctx)) { + const kids = ctx.gather + .map(stripActivation) + .filter((c): c is Ctx => c !== undefined); + if (kids.length === 0) return undefined; + if (kids.length === 1) return kids[0]; + return { gather: kids } as Ctx; + } + const rest = { ...(ctx as Record) }; + delete rest.invoke; + delete rest.return; + return Object.keys(rest).length > 0 ? (rest as Ctx) : undefined; +} + +/** Attach an activation discriminator, composing it as a flat sibling + * key on a leaf context (per the flat-composition convention), or + * appending it to a pick/gather composite. */ +function attachActivation( + ctx: Ctx | undefined, + key: Activation, + value: unknown, +): Ctx { + const marker = { [key]: value } as Ctx; + if (!ctx || typeof ctx !== "object") return marker; + if (isPick(ctx)) return { pick: [...ctx.pick, marker] } as Ctx; + if (isGather(ctx)) return { gather: [...ctx.gather, marker] } as Ctx; + return { ...(ctx as Record), [key]: value } as Ctx; +} + +/** + * Rewrite the ops emitted by one IR instruction (the tail slice + * `instructions[start..]`) so invoke rides only the first op and + * return only the last op, using the discriminators found on the + * instruction's `operationDebug` context. No-op unless that context + * carries invoke and/or return, so it never touches ordinary code. + */ +export function bracketActivation( + instructions: Evm.Instruction[], + start: number, + operationCtx: Ctx | undefined, +): Evm.Instruction[] { + const end = instructions.length; // exclusive + if (end <= start) return instructions; + + const hasInvoke = carriesActivation(operationCtx, "invoke"); + const hasReturn = carriesActivation(operationCtx, "return"); + if (!hasInvoke && !hasReturn) return instructions; + + const invokeValue = hasInvoke + ? findActivation(operationCtx, "invoke") + : undefined; + const returnValue = hasReturn + ? findActivation(operationCtx, "return") + : undefined; + + const out = instructions.slice(); + for (let i = start; i < end; i++) { + const op = out[i]; + let ctx = stripActivation(op.debug?.context); + if (hasInvoke && i === start) { + ctx = attachActivation(ctx, "invoke", invokeValue); + } + if (hasReturn && i === end - 1) { + ctx = attachActivation(ctx, "return", returnValue); + } + out[i] = { ...op, debug: { ...op.debug, context: ctx } }; + } + return out; +} diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 0117bf24e..933f104f8 100644 --- a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts +++ b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts @@ -1,5 +1,6 @@ import type * as Format from "@ethdebug/format"; import type * as Ir from "#ir"; +import { Utils as IrUtils } from "#ir"; import type * as Evm from "#evm"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; @@ -411,10 +412,11 @@ function generateReturnEpilogue( /** * Build JUMP instruction options for a TCO-replaced tail call. * - * The JUMP carries BOTH discriminators on a single flat - * context object: + * The JUMP carries three keys on a single flat context + * object: * - return: the previous iteration's return * - invoke: the new iteration's call + * - transform: ["tailcall"] * * Semantically the debugger sees frame depth stay constant * across the back-edge JUMP: the previous frame pops, the @@ -422,6 +424,13 @@ function generateReturnEpilogue( * terminal RETURN (elsewhere) emits a return context * normally, popping the final iteration's frame. * + * The `transform: ["tailcall"]` key is an additive + * annotation: it does not replace the invoke/return pair + * (which state the source-level facts) but tells debuggers + * the pair was realized as a TCO back-edge rather than a + * real frame push/pop, so they can avoid inventing a + * spurious frame. + * * The invoke mirrors the normal caller-JUMP invoke * (identity + declaration + code target, no argument * pointers). The return omits `data` because TCO does not @@ -432,7 +441,7 @@ function generateReturnEpilogue( * resolved later by patchInvokeTarget. */ function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): { - debug: { context: Format.Program.Context }; + debug: Ir.Instruction.Debug; } { const declaration = tailCall.declarationLoc && tailCall.declarationSourceId @@ -462,7 +471,16 @@ function buildTailCallJumpOptions(tailCall: Ir.Block.TailCall): { }, }; - return { debug: { context: combined as Format.Program.Context } }; + // Route through the shared helper so all transform emission + // (fold/tailcall/coalesce/...) composes consistently: the + // `transform` marker becomes a flat sibling key appended to + // any existing transform array. + return { + debug: IrUtils.addTransform( + { context: combined as Format.Program.Context }, + "tailcall", + ), + }; } /** PUSH an integer as the smallest PUSHn. */ diff --git a/packages/bugc/src/evmgen/inline-bracket.test.ts b/packages/bugc/src/evmgen/inline-bracket.test.ts new file mode 100644 index 000000000..6583bcfed --- /dev/null +++ b/packages/bugc/src/evmgen/inline-bracket.test.ts @@ -0,0 +1,192 @@ +/** + * Verifies that inlined virtual-activation invoke/return contexts are + * BRACKETED on the emitted bytecode, not smeared across every op. + * + * An IR instruction lowers to N EVM micro-ops. evmgen must attach the + * `invoke` discriminator to only the FIRST emitted op of the + * invoke-bearing instruction and the `return` discriminator to only the + * LAST emitted op of the return-bearing instruction, while keeping the + * `transform: ["inline"]` membership marker on ALL body ops. + * + * Without bracketing, the tracer's push/pop reconstruction sees every + * body op as both a push and a pop -> phantom frames. + */ +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; + +async function runtimeInstructions(source: string, level: OptLevel) { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) { + const errors = result.messages.error ?? []; + throw new Error( + `Compilation failed at level ${level}:\n` + + errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"), + ); + } + return result.value.bytecode.runtimeInstructions; +} + +/** Flatten a context into leaves, unwrapping gather/pick. */ +function leaves(ctx: Format.Program.Context): Format.Program.Context[] { + if (Context.isGather(ctx)) return ctx.gather.flatMap(leaves); + if ("pick" in ctx && Array.isArray((ctx as { pick: unknown[] }).pick)) { + return (ctx as { pick: Format.Program.Context[] }).pick.flatMap(leaves); + } + return [ctx]; +} + +/** Per-op discriminator/marker presence, reaching nested pick/gather. */ +function flags(instr: { debug?: { context?: Format.Program.Context } }) { + const ctx = instr.debug?.context; + if (!ctx) return { invoke: false, return: false, inline: false }; + const all = [ctx, ...leaves(ctx)]; + return { + invoke: all.some((c) => Context.isInvoke(c)), + return: all.some((c) => Context.isReturn(c)), + inline: all.some( + (c) => Context.isTransform(c) && c.transform.includes("inline"), + ), + }; +} + +function tally(instrs: ReturnType[]) { + let invoke = 0, + ret = 0, + both = 0, + inline = 0; + for (const f of instrs) { + if (f.invoke) invoke += 1; + if (f.return) ret += 1; + if (f.invoke && f.return) both += 1; + if (f.inline) inline += 1; + } + return { invoke, ret, both, inline }; +} + +// The exact fixture the UI reported mis-rendering: a leaf helper +// inlined at two sites. +const dblTwoSites = `name Multi; +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; +}`; + +// A multi-instruction body: entry (t = x + x) differs from exit +// (t * x), so invoke and return live on distinct IR instructions. +const multiInstrBody = `name Poly; +define { function poly(x: uint256) -> uint256 { let t = x + x; return t * x; }; } +storage { [0] a: uint256; [1] r: uint256; } +create { a = 3; r = 0; } +code { r = poly(a); }`; + +describe("inlined invoke/return are bracketed on emitted bytecode", () => { + it("dbl@2-sites: one push and one pop per site, never both on an op", async () => { + const instrs = await runtimeInstructions(dblTwoSites, 2); + const t = tally(instrs.map(flags)); + // Two inlined sites => exactly one invoke op and one return op each. + expect(t.invoke).toBe(2); + expect(t.ret).toBe(2); + // No op may be both a push and a pop (that breaks push/pop). + expect(t.both).toBe(0); + // Membership marker stays on every body op (more than the 4 + // boundary ops). + expect(t.inline).toBeGreaterThan(4); + }); + + it("dbl@2-sites: each site's invoke op precedes its return op", async () => { + const instrs = await runtimeInstructions(dblTwoSites, 2); + const seq = instrs + .map((instr, i) => ({ i, f: flags(instr) })) + .filter(({ f }) => f.invoke || f.return) + .map(({ f }) => (f.invoke ? "invoke" : "return")); + // Bracketed order across two sites: push,pop,push,pop. + expect(seq).toEqual(["invoke", "return", "invoke", "return"]); + }); + + it("multi-instruction body: invoke on entry, return on exit, both=0", async () => { + const instrs = await runtimeInstructions(multiInstrBody, 2); + const t = tally(instrs.map(flags)); + expect(t.invoke).toBe(1); + expect(t.ret).toBe(1); + expect(t.both).toBe(0); + }); + + it("preserves runtime behavior at every level", async () => { + for (const level of [0, 1, 2, 3] as const) { + const res = await executeProgram(dblTwoSites, { + calldata: "", + optimizationLevel: level, + }); + expect(res.callSuccess).toBe(true); + // dbl(5)=10, dbl(10)=20, r=30 + expect(await res.getStorage(0n)).toBe(30n); + } + }); +}); + +// A self-tail-recursive accumulator: TCO turns the recursive call +// into a single back-edge JUMP that legitimately carries BOTH invoke +// and return on its one op (end one iteration + begin the next). +const tailRecursive = `name TailSum; +define { + function sum(n: uint256, acc: uint256) -> uint256 { + if (n == 0) { return acc; } + else { return sum(n - 1, acc + n); } + }; +} +storage { [0] result: uint256; } +create { result = 0; } +code { result = sum(5, 0); }`; + +// Mutually recursive functions never inline, so their calls stay real +// (invoke on a 1-op JUMP, return on a 1-op JUMPDEST). +const mutualRecursion = `name EvenOdd; +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] result: uint256; } +create { result = 0; } +code { result = isEven(4); }`; + +describe("bracketing is a no-op for single-op invoke/return carriers", () => { + it("tailcall back-edge keeps its combined invoke+return on one op", async () => { + // The back-edge JUMP is a single op carrying both markers; bracketing + // to first-op/last-op is first==last, so both must survive. + const instrs = await runtimeInstructions(tailRecursive, 2); + const t = tally(instrs.map(flags)); + expect(t.both).toBeGreaterThanOrEqual(1); + }); + + it("real (non-inlined) calls never carry both on an op", async () => { + const instrs = await runtimeInstructions(mutualRecursion, 2); + const t = tally(instrs.map(flags)); + // Real calls put invoke on a 1-op JUMP and return on a 1-op JUMPDEST, + // distinct ops — the fix must not fabricate a both. + expect(t.both).toBe(0); + expect(t.invoke).toBeGreaterThan(0); + expect(t.ret).toBeGreaterThan(0); + }); +}); diff --git a/packages/bugc/src/evmgen/optimizer-contexts.test.ts b/packages/bugc/src/evmgen/optimizer-contexts.test.ts index 51805d7ff..fbf11e5ac 100644 --- a/packages/bugc/src/evmgen/optimizer-contexts.test.ts +++ b/packages/bugc/src/evmgen/optimizer-contexts.test.ts @@ -150,8 +150,8 @@ code { r = add(10, 20); }`; if (level >= 2) { // `add` is a leaf single-return helper: inlining (L2+) - // splices its body into the caller, so there's no real - // caller JUMP for `add`. + // replaces the real call with a virtual inline + // activation, so there's no caller JUMP for `add`. expect(counts.invokeJump).toEqual({}); } else { // One caller JUMP, one callee JUMPDEST, one @@ -193,8 +193,8 @@ code { r = add(2 + 3, 4 * 5); }`; const counts = countCallSites(program); if (level >= 2) { - // `add` inlined at L2+ — its body is spliced in, no real - // caller JUMP. Constant-foldable args don't change that. + // `add` inlined at L2+ — virtual inline activation, no + // real caller JUMP. expect(counts.invokeJump).toEqual({}); } else { expect(counts.invokeJump).toEqual({ add: 1 }); @@ -238,8 +238,8 @@ code { const counts = countCallSites(program); if (level >= 2) { - // Both `dbl` sites are leaf single-return calls, inlined - // at L2+ into the caller — no real caller JUMPs remain. + // Both `dbl` sites are inlined (leaf single-return) into + // separate virtual activations; no real caller JUMPs. expect(counts.invokeJump).toEqual({}); } else { expect(counts.invokeJump).toEqual({ dbl: 2 }); @@ -372,7 +372,8 @@ code { r = addThree(1, 2, 3); }`; // `add` (leaf) inlines into `addThree` at both sites; // that makes `addThree` itself a leaf, so on a later // fixpoint iteration it inlines into `main` too. End - // state: no real caller JUMPs remain. + // state: no real caller JUMPs — everything is inline + // activations. expect(counts.invokeJump).toEqual({}); } else { expect(counts.invokeJump).toEqual({ @@ -524,6 +525,7 @@ code { r = count(0, 5); }`; Context.isReturn(instr.context), ); expect(tcoJump).toBeDefined(); + const ctx = tcoJump!.context as Format.Program.Context.Invoke; const invocation = ctx.invoke; expect(Invocation.isInternalCall(invocation)).toBe(true); diff --git a/packages/bugc/src/evmgen/transform-contexts.test.ts b/packages/bugc/src/evmgen/transform-contexts.test.ts new file mode 100644 index 000000000..28e93bfff --- /dev/null +++ b/packages/bugc/src/evmgen/transform-contexts.test.ts @@ -0,0 +1,181 @@ +/** + * Verifies that optimizer `transform` markers are emitted onto + * the resulting bytecode's debug contexts, on the same + * `runtimeInstructions` path the docs tracer widget consumes. + * + * Level 1 constant folding attaches `transform: ["fold"]` to the + * folded value's instruction; a debugger can then show that a + * PUSH is a compile-time-evaluated constant rather than source + * the user wrote. + */ +import { describe, it, expect } from "vitest"; + +import { compile } from "#compiler"; +import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; + +const { Context } = Program; + +type OptLevel = 0 | 1 | 2 | 3; + +async function compileBytecode(source: string, level: OptLevel) { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) { + const errors = result.messages.error ?? []; + throw new Error( + `Compilation failed at level ${level}:\n` + + errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"), + ); + } + return result.value.bytecode; +} + +/** Flatten a context into leaves, unwrapping gather/pick. */ +function leaves(ctx: Format.Program.Context): Format.Program.Context[] { + if (Context.isGather(ctx)) return ctx.gather.flatMap(leaves); + if ("pick" in ctx && Array.isArray((ctx as { pick: unknown[] }).pick)) { + return (ctx as { pick: Format.Program.Context[] }).pick.flatMap(leaves); + } + return [ctx]; +} + +/** + * Count instructions in the widget-path array whose context + * (at top level or in any leaf) carries a transform containing + * the given identifier. + */ +function countTransform( + instructions: { debug?: { context?: Format.Program.Context } }[], + id: string, +): number { + let count = 0; + for (const instr of instructions) { + const ctx = instr.debug?.context; + if (!ctx) continue; + const hit = [ctx, ...leaves(ctx)].some( + (c) => Context.isTransform(c) && c.transform.includes(id), + ); + if (hit) count += 1; + } + return count; +} + +describe("optimizer emits tailcall transform contexts", () => { + // `sum` is tail-recursive: the recursive call is in return + // position. Tail-call optimization (level 2+) rewrites it into a + // back-edge JUMP carrying `transform: ["tailcall"]`. + const source = `name TailSum; + +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 { r = sum(5, 0); }`; + + for (const level of [0, 1] as const) { + it(`emits no tailcall transform at level ${level}`, async () => { + const bc = await compileBytecode(source, level); + expect(countTransform(bc.runtimeInstructions, "tailcall")).toBe(0); + }); + } + + for (const level of [2, 3] as const) { + it(`emits tailcall transform at level ${level}`, async () => { + const bc = await compileBytecode(source, level); + expect( + countTransform(bc.runtimeInstructions, "tailcall"), + ).toBeGreaterThan(0); + }); + } +}); + +describe("optimizer emits fold transform contexts", () => { + // `2 + 3` and `4 * 5` fold to constants at level 1. + const source = `name Fold; + +storage { [0] r: uint256; } +create { r = 0; } +code { r = (2 + 3) * (4 * 5); }`; + + it("emits no fold transform at level 0", async () => { + const bc = await compileBytecode(source, 0); + expect(countTransform(bc.runtimeInstructions, "fold")).toBe(0); + }); + + for (const level of [1, 2, 3] as const) { + it(`emits fold transform at level ${level}`, async () => { + const bc = await compileBytecode(source, level); + expect(countTransform(bc.runtimeInstructions, "fold")).toBeGreaterThan(0); + }); + } +}); + +describe("optimizer emits coalesce transform contexts", () => { + // Two adjacent packed writes of a runtime value to one storage + // slot; read/write merging (level 3) packs them with SHL/OR into + // a single word write. + const source = `name Coalesce; + +define { struct S { a: uint128; b: uint128; }; } +storage { [0] s: S; [1] src: uint256; } +create {} +code { let v = src; s.a = v; s.b = v; }`; + + for (const level of [0, 1, 2] as const) { + it(`emits no coalesce transform at level ${level}`, async () => { + const bc = await compileBytecode(source, level); + expect(countTransform(bc.runtimeInstructions, "coalesce")).toBe(0); + }); + } + + it("emits coalesce transform at level 3", async () => { + const bc = await compileBytecode(source, 3); + expect(countTransform(bc.runtimeInstructions, "coalesce")).toBeGreaterThan( + 0, + ); + }); +}); + +describe("optimizer emits inline transform contexts", () => { + // `dbl` is a leaf single-return helper: inlining (level 2+) + // splices its body into the caller, marking the inlined + // instructions with `transform: ["inline"]`. The argument is a + // storage read (non-constant), so the inlined body cannot be + // constant-folded away and the marker survives at levels 2 and 3. + const source = `name Inline; + +define { + function dbl(x: uint256) -> uint256 { return x + x; }; +} + +storage { [0] r: uint256; [1] src: uint256; } +create {} +code { r = dbl(src); }`; + + for (const level of [0, 1] as const) { + it(`emits no inline transform at level ${level}`, async () => { + const bc = await compileBytecode(source, level); + expect(countTransform(bc.runtimeInstructions, "inline")).toBe(0); + }); + } + + for (const level of [2, 3] as const) { + it(`emits inline transform at level ${level}`, async () => { + const bc = await compileBytecode(source, level); + expect(countTransform(bc.runtimeInstructions, "inline")).toBeGreaterThan( + 0, + ); + }); + } +}); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 5feb48b9c..13516fe71 100644 --- a/packages/bugc/src/ir/utils/debug.ts +++ b/packages/bugc/src/ir/utils/debug.ts @@ -350,3 +350,36 @@ export function preserveSubInstructionDebug( ...additionalContexts.map((c) => ({ context: c })), ); } + +/** + * Add one or more `transform` optimization markers to a debug + * context, composing them as a flat sibling key alongside any + * existing context discriminators (per the flat-composition + * convention: gather is only for same-key collisions). + * + * Markers are appended to any existing `transform` array on the + * context, so an instruction touched by multiple passes + * accumulates the multiset — e.g. a folded value later merged + * yields `transform: ["fold", "coalesce"]`. + */ +export function addTransform( + debug: Ir.Instruction.Debug | undefined, + ...ids: Format.Program.Context.Transform.Identifier[] +): Ir.Instruction.Debug { + const existing = debug?.context; + + const prior: Format.Program.Context.Transform.Identifier[] = + existing && + "transform" in existing && + Array.isArray((existing as Format.Program.Context.Transform).transform) + ? (existing as Format.Program.Context.Transform).transform + : []; + + const transform = [...prior, ...ids]; + + if (!existing) { + return { context: { transform } }; + } + + return { context: { ...existing, transform } }; +} diff --git a/packages/bugc/src/optimizer/simple-optimizer.ts b/packages/bugc/src/optimizer/simple-optimizer.ts index 1b52a0cdb..7c64642d7 100644 --- a/packages/bugc/src/optimizer/simple-optimizer.ts +++ b/packages/bugc/src/optimizer/simple-optimizer.ts @@ -61,7 +61,8 @@ function createOptimizationPipeline(level: number): OptimizationStep[] { // Level 2: Add inlining, CSE, tail call optimization, and // jump optimization. Inlining runs first (after L1 fold) so - // TCO/CSE still apply to inlined code. + // TCO/CSE still apply to inlined code and `["fold","inline"]` + // composes. if (level >= 2) { steps.push( new InliningStep(), diff --git a/packages/bugc/src/optimizer/steps/constant-folding.ts b/packages/bugc/src/optimizer/steps/constant-folding.ts index 20f99d585..725c7f530 100644 --- a/packages/bugc/src/optimizer/steps/constant-folding.ts +++ b/packages/bugc/src/optimizer/steps/constant-folding.ts @@ -134,7 +134,10 @@ export class ConstantFoldingStep extends BaseOptimizationStep { value: result, type: this.getResultType(inst.op, typeof result), dest: inst.dest, - operationDebug: Ir.Utils.preserveDebug(inst), + operationDebug: Ir.Utils.addTransform( + Ir.Utils.preserveDebug(inst), + "fold", + ), }; } @@ -266,7 +269,10 @@ export class ConstantFoldingStep extends BaseOptimizationStep { value: hashValue, type: Ir.Type.Scalar.bytes32, dest: inst.dest, - operationDebug: Ir.Utils.preserveDebug(inst), + operationDebug: Ir.Utils.addTransform( + Ir.Utils.preserveDebug(inst), + "fold", + ), }; } diff --git a/packages/bugc/src/optimizer/steps/inlining.test.ts b/packages/bugc/src/optimizer/steps/inlining.test.ts index 668074784..36a4d5919 100644 --- a/packages/bugc/src/optimizer/steps/inlining.test.ts +++ b/packages/bugc/src/optimizer/steps/inlining.test.ts @@ -2,15 +2,21 @@ * Behavioral + structural tests for the function-inlining pass * (level 2). * - * Inlining must (a) preserve runtime behavior exactly, and - * (b) actually replace eligible calls with the callee's body — - * a fully-inlined callee is deleted and no `call` to it remains. + * Inlining must (a) preserve runtime behavior exactly, (b) actually + * replace eligible calls with the callee's body — a fully-inlined + * callee is deleted and no `call` to it remains — and (c) emit + * `transform: ["inline"]` on the inlined body so the debugger can + * reconstruct a virtual activation for the call. */ import { describe, it, expect } from "vitest"; import { compile } from "#compiler"; import * as Ir from "#ir"; import { executeProgram } from "#test/evm/behavioral"; +import type * as Format from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; + +const { Context } = Program; async function optimizedIr( source: string, @@ -54,6 +60,48 @@ function countCalls(module: Ir.Module, callee: string): number { return n; } +function leaves(ctx: Format.Program.Context): Format.Program.Context[] { + if (Context.isGather(ctx)) return ctx.gather.flatMap(leaves); + if ("pick" in ctx && Array.isArray((ctx as { pick: unknown[] }).pick)) { + return (ctx as { pick: Format.Program.Context[] }).pick.flatMap(leaves); + } + return [ctx]; +} + +/** Count instructions carrying a `transform: ["inline"]` marker. */ +async function inlineMarks( + source: string, + level: 0 | 1 | 2 | 3, +): Promise { + const result = await compile({ + to: "bytecode", + source, + optimizer: { level }, + }); + if (!result.success) { + const errors = result.messages.error ?? []; + throw new Error( + "compile failed:\n" + + errors + .map((e: { message?: string }) => e.message ?? String(e)) + .join("\n"), + ); + } + let count = 0; + for (const instr of result.value.bytecode.runtimeInstructions) { + const ctx = instr.debug?.context; + if (!ctx) continue; + if ( + [ctx, ...leaves(ctx)].some( + (c) => Context.isTransform(c) && c.transform.includes("inline"), + ) + ) { + count += 1; + } + } + return count; +} + describe("function inlining (level 2)", () => { describe("leaf helper, single return", () => { const source = `name Demo; @@ -88,6 +136,14 @@ code { r = add(3, 4); }`; expect(countCalls(ir, "add")).toBe(0); expect(ir.functions.has("add")).toBe(false); }); + + it("emits no inline marks at level 0", async () => { + expect(await inlineMarks(source, 0)).toBe(0); + }); + + it("emits inline marks at level 2", async () => { + expect(await inlineMarks(source, 2)).toBeGreaterThan(0); + }); }); describe("multiple call sites", () => { @@ -112,10 +168,11 @@ code { expect(res.callSuccess).toBe(true); expect(await res.getStorage(0n)).toBe(30n); } - // Both sites inlined; callee deleted. + // Both sites inlined; callee deleted; body marked. const ir = await optimizedIr(source, 2); expect(countCalls(ir, "dbl")).toBe(0); expect(ir.functions.has("dbl")).toBe(false); + expect(await inlineMarks(source, 2)).toBeGreaterThan(0); }); }); @@ -152,6 +209,8 @@ code { r = count(0, 5); }`; const ir = await optimizedIr(source, 2); expect(ir.functions.has("succ")).toBe(true); expect(countCalls(ir, "succ")).toBeGreaterThan(0); + // And no inline markers: succ was not inlined. + expect(await inlineMarks(source, 2)).toBe(0); }); }); }); diff --git a/packages/bugc/src/optimizer/steps/inlining.ts b/packages/bugc/src/optimizer/steps/inlining.ts index e2bd4b712..3cac38370 100644 --- a/packages/bugc/src/optimizer/steps/inlining.ts +++ b/packages/bugc/src/optimizer/steps/inlining.ts @@ -3,16 +3,19 @@ * * Replaces calls to eligible internal functions with a copy of * the callee's body spliced into the caller, so no runtime - * JUMP/frame is used. Inlined instructions keep their original - * source mapping (the callee's `operationDebug`), so inlined code - * still maps back to the callee. + * JUMP/frame is used. Each inlined instruction is annotated with + * `transform: ["inline"]` and the body is bracketed by a virtual + * invoke/return (identity + declaration, no code target — the + * #213 optional-target signal) so a debugger can reconstruct a + * virtual activation. * - * v1 eligibility: internal (user-defined), non-recursive callee + * Current eligibility: internal (user-defined), non-recursive callee * that is either a leaf (calls nothing) or below a small size * threshold. Applied at all call sites; a callee whose every * site is inlined is deleted. */ import * as Ir from "#ir"; +import type * as Format from "@ethdebug/format"; import { BaseOptimizationStep, @@ -155,17 +158,49 @@ export class InliningStep extends BaseOptimizationStep { return nid ? { ...v, id: nid } : v; }; + // Declaration for the callee (for the virtual invoke/return). + const declaration = + callee.loc && callee.sourceId + ? { source: { id: callee.sourceId }, range: callee.loc } + : undefined; + + const inlineInvoke: Format.Program.Context.Invoke["invoke"] = { + jump: true, + identifier: callee.name, + ...(declaration ? { declaration } : {}), + // no `target` — JUMP is elided (virtual activation) + }; + const inlineReturn: Format.Program.Context.Return["return"] = { + identifier: callee.name, + ...(declaration ? { declaration } : {}), + }; + const entryBlockId = blockRename.get(callee.entry)!; + const returnBlockIds: string[] = []; // --- clone + remap callee blocks --- for (const [origId, origBlock] of callee.blocks) { const newId = blockRename.get(origId)!; + const isEntry = origId === callee.entry; - // Cloned instructions keep their preserved source - // `operationDebug` (remapInstruction deep-clones it), so - // inlined code still maps back to the callee's source. const instructions: Ir.Instruction[] = origBlock.instructions.map( - (inst) => remapInstruction(inst, remapValue, idRename), + (inst, idx) => { + const cloned = remapInstruction(inst, remapValue, idRename); + // Mark every inlined instruction for membership. + cloned.operationDebug = Ir.Utils.addTransform( + cloned.operationDebug, + "inline", + ); + // Virtual invoke on the first instruction of the entry. + if (isEntry && idx === 0) { + cloned.operationDebug = mergeDiscriminator( + cloned.operationDebug, + "invoke", + inlineInvoke, + ); + } + return cloned; + }, ); const phis: Ir.Block.Phi[] = (origBlock.phis ?? []).map((phi) => @@ -175,12 +210,28 @@ export class InliningStep extends BaseOptimizationStep { let terminator: Ir.Block.Terminator; const t = origBlock.terminator; if (t.kind === "return") { - // return -> jump to the caller's continuation, preserving - // the return's own source mapping. + returnBlockIds.push(newId); + // Virtual return marker on the last body instruction of + // this block (or a synthetic carrier if the block is empty + // is not needed — return blocks always have ≥1 emitted + // instruction in practice; if empty, the marker rides the + // jump's debug below). + if (instructions.length > 0) { + const last = instructions[instructions.length - 1]; + last.operationDebug = mergeDiscriminator( + last.operationDebug, + "return", + inlineReturn, + ); + } + // return -> jump to the caller's continuation terminator = { kind: "jump", target: call.continuation, - operationDebug: t.operationDebug, + operationDebug: Ir.Utils.addTransform( + mergeDiscriminator({}, "return", inlineReturn), + "inline", + ), }; } else { terminator = remapTerminator(t, remapValue, blockRename); @@ -197,7 +248,7 @@ export class InliningStep extends BaseOptimizationStep { } // --- wire the single return value into the caller --- - // v1 eligibility guarantees exactly one return. Substitute the + // Current eligibility guarantees exactly one return. Substitute the // call's dest temp with the (remapped) returned value across the // whole caller — no phi, so it's robust to L3 block-merging. if (call.dest) { @@ -306,7 +357,7 @@ function isEligible( const callees = graph.get(name) ?? new Set(); // Non-recursive: name not reachable from itself. if (reachableCallees(name, graph).has(name)) return false; - // v1: single return point only. Multi-return needs a phi at the + // For now, single return point only. Multi-return needs a phi at the // continuation, which block-merging (L3) can turn into an // invalid self-referential phi; deferred until that's handled. if (returnCount(fn) !== 1) return false; @@ -319,7 +370,7 @@ function isEligible( for (const b of fn.blocks.values()) { if (b.terminator.kind === "jump" && b.terminator.tailCall) return false; } - // v1: leaf callees only. Inlining a non-leaf callee whose own + // For now, leaf callees only. Inlining a non-leaf callee whose own // (eligible) calls also inline exposes a dest-substitution // ordering bug in the nested chain; deferred. The size-threshold // branch is kept for when that lands. @@ -478,3 +529,33 @@ function recomputePredecessors(fn: Ir.Function): void { } } } + +// ---- debug-context composition ---- + +/** + * Attach a discriminator (invoke/return) as a flat sibling key on + * a debug context, threading into a gather leaf if present so the + * marker never sits as a sibling of `gather`. + */ +function mergeDiscriminator( + debug: Ir.Instruction.Debug, + key: "invoke" | "return", + value: unknown, +): Ir.Instruction.Debug { + const existing = debug.context as Record | undefined; + if (existing && "gather" in existing && Array.isArray(existing.gather)) { + // Add as a new gather child rather than a sibling of gather. + return { + context: { + ...existing, + gather: [...(existing.gather as unknown[]), { [key]: value }], + } as Format.Program.Context, + }; + } + return { + context: { + ...(existing ?? {}), + [key]: value, + } as Format.Program.Context, + }; +} diff --git a/packages/bugc/src/optimizer/steps/read-write-merging.ts b/packages/bugc/src/optimizer/steps/read-write-merging.ts index 1ac2ecde5..cb24ba1c7 100644 --- a/packages/bugc/src/optimizer/steps/read-write-merging.ts +++ b/packages/bugc/src/optimizer/steps/read-write-merging.ts @@ -368,6 +368,18 @@ export class ReadWriteMergingStep extends BaseOptimizationStep { reason: `Merged ${writes.length} writes to same location`, }); + // Mark every instruction produced by the merge with + // transform:["coalesce"] so debuggers can show the SHL/OR + // field-packing sequence as compiler-synthesized rather than + // source the user wrote. Appends to any existing transform + // array (e.g. a folded packed value → ["fold","coalesce"]). + for (const inst of instructions) { + inst.operationDebug = Ir.Utils.addTransform( + inst.operationDebug, + "coalesce", + ); + } + return instructions; } diff --git a/packages/format/src/types/program/context.test.ts b/packages/format/src/types/program/context.test.ts index 88bbf537c..55a716181 100644 --- a/packages/format/src/types/program/context.test.ts +++ b/packages/format/src/types/program/context.test.ts @@ -50,4 +50,8 @@ testSchemaGuards("ethdebug/format/program/context", [ schema: "schema:ethdebug/format/program/context/function/revert", guard: Context.isRevert, }, + { + schema: "schema:ethdebug/format/program/context/transform", + guard: Context.isTransform, + }, ] as const); diff --git a/packages/format/src/types/program/context.ts b/packages/format/src/types/program/context.ts index 14ab33c4a..d6834bdb7 100644 --- a/packages/format/src/types/program/context.ts +++ b/packages/format/src/types/program/context.ts @@ -12,7 +12,8 @@ export type Context = | Context.Frame | Context.Invoke | Context.Return - | Context.Revert; + | Context.Revert + | Context.Transform; export const isContext = (value: unknown): value is Context => [ @@ -26,6 +27,7 @@ export const isContext = (value: unknown): value is Context => Context.isInvoke, Context.isReturn, Context.isRevert, + Context.isTransform, ].some((guard) => guard(value)); export namespace Context { @@ -291,4 +293,30 @@ export namespace Context { (!("panic" in value) || typeof value.panic === "number") && (!("activation" in value) || typeof value.activation === "string"); } + + export interface Transform { + transform: Transform.Identifier[]; + } + + export const isTransform = (value: unknown): value is Transform => + typeof value === "object" && + !!value && + "transform" in value && + Array.isArray(value.transform) && + value.transform.length > 0 && + value.transform.every( + (item) => typeof item === "string" && item.length > 0, + ); + + export namespace Transform { + // Recognized identifiers. Unknown strings are permitted + // (the identifier set is extensible); the union preserves + // autocomplete for known values. + export type Identifier = + | "inline" + | "tailcall" + | "fold" + | "coalesce" + | (string & {}); + } } diff --git a/packages/programs-react/src/components/CallInfoPanel.css b/packages/programs-react/src/components/CallInfoPanel.css index 75cd06511..b2835861c 100644 --- a/packages/programs-react/src/components/CallInfoPanel.css +++ b/packages/programs-react/src/components/CallInfoPanel.css @@ -27,6 +27,12 @@ border-left: 3px solid var(--programs-revert-accent, #cf222e); } +.call-info-banner-tailcall { + background: var(--programs-transform-bg, #f3ecff); + color: var(--programs-transform-text, #8250df); + border-left: 3px solid var(--programs-transform-accent, #a475f9); +} + .call-info-refs { display: flex; flex-direction: column; diff --git a/packages/programs-react/src/components/CallInfoPanel.tsx b/packages/programs-react/src/components/CallInfoPanel.tsx index 09da2b3d7..e0e10ed2b 100644 --- a/packages/programs-react/src/components/CallInfoPanel.tsx +++ b/packages/programs-react/src/components/CallInfoPanel.tsx @@ -29,6 +29,10 @@ function formatBanner(info: ResolvedCallInfo): string { ? `(${info.argumentNames.join(", ")})` : "()"; + if (info.isTailCall) { + return `Tail call: ${name} (frame reused)`; + } + if (info.kind === "invoke") { const prefix = info.callType === "external" @@ -50,11 +54,14 @@ function formatBanner(info: ResolvedCallInfo): string { return `Reverted in ${name}()`; } -function bannerClassName(kind: ResolvedCallInfo["kind"]): string { - if (kind === "invoke") { +function bannerClassName(info: ResolvedCallInfo): string { + if (info.isTailCall) { + return "call-info-banner-tailcall"; + } + if (info.kind === "invoke") { return "call-info-banner-invoke"; } - if (kind === "return") { + if (info.kind === "return") { return "call-info-banner-return"; } return "call-info-banner-revert"; @@ -76,9 +83,7 @@ export function CallInfoPanel({ return (
-
+
{formatBanner(currentCallInfo)}
diff --git a/packages/programs-react/src/components/CallStackDisplay.css b/packages/programs-react/src/components/CallStackDisplay.css index 9143b8d76..33790e6a2 100644 --- a/packages/programs-react/src/components/CallStackDisplay.css +++ b/packages/programs-react/src/components/CallStackDisplay.css @@ -48,3 +48,23 @@ .call-stack-parens { color: var(--programs-text-muted, #888); } + +.call-stack-tailcall, +.call-stack-inline { + margin-left: 4px; + padding: 0 5px; + border-radius: 8px; + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + background: var(--programs-transform-bg, #f3ecff); + color: var(--programs-transform-text, #8250df); + border: 1px solid var(--programs-transform-accent, #a475f9); +} + +/* Virtual (inline) activations use a dashed border to read as + "not a real frame" while sharing the transform palette. */ +.call-stack-inline { + border-style: dashed; + font-style: italic; +} diff --git a/packages/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx index 09e2bf7aa..f8dfc4b05 100644 --- a/packages/programs-react/src/components/CallStackDisplay.tsx +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -94,6 +94,22 @@ export function CallStackDisplay({ ({formatArgs(frame, resolvedCallStack)}) + {frame.isTailCall && ( + + ⮌ tail call + + )} + {frame.isInline && ( + + ⧉ inline + + )} ))} diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 581b72143..795f3f2f4 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -118,6 +118,8 @@ export interface ResolvedCallInfo { panic?: number; /** Resolved pointer refs */ pointerRefs: ResolvedPointerRef[]; + /** True when a tailcall transform is present (TCO). */ + isTailCall?: boolean; } /** @@ -136,6 +138,8 @@ export interface ResolvedCallFrame { value?: string; error?: string; }>; + /** True when this frame was (re)entered via a tail call. */ + isTailCall?: boolean; } /** @@ -382,6 +386,7 @@ export function TraceProvider({ identifier: frame.identifier, stepIndex: frame.stepIndex, callType: frame.callType, + isTailCall: frame.isTailCall, resolvedArgs: argCacheRef.current.get(frame.stepIndex), })); setResolvedCallStack(initial); @@ -477,6 +482,7 @@ export function TraceProvider({ callType: extractedCallInfo.callType, argumentNames: extractedCallInfo.argumentNames, panic: extractedCallInfo.panic, + isTailCall: extractedCallInfo.isTailCall, pointerRefs: extractedCallInfo.pointerRefs.map((ref) => ({ label: ref.label, pointer: ref.pointer, diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 6253933c2..8d8610771 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -59,6 +59,7 @@ export { findInstructionAtPc, extractVariablesFromInstruction, extractCallInfoFromInstruction, + extractTransformFromInstruction, buildPcToInstructionMap, buildCallStack, type CallInfo, diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts index a79f07b08..e6dfdbefb 100644 --- a/packages/programs-react/src/utils/index.ts +++ b/packages/programs-react/src/utils/index.ts @@ -18,6 +18,7 @@ export { findInstructionAtPc, extractVariablesFromInstruction, extractCallInfoFromInstruction, + extractTransformFromInstruction, buildPcToInstructionMap, buildCallStack, type TraceStep, diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts index 531cfab43..21ae3e431 100644 --- a/packages/programs-react/src/utils/mockTrace.test.ts +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -1,5 +1,6 @@ /** - * Tests for call-stack construction, with focus on the flat + * Tests for trace context extraction, transform (tailcall) + * detection, and call-stack construction — including the flat * tail-call back-edge shape: a single instruction that carries * both a `return` and an `invoke` context. */ @@ -7,6 +8,9 @@ import { describe, it, expect } from "vitest"; import type { Program } from "@ethdebug/format"; import { + extractTransformFromInstruction, + extractCallInfoFromInstruction, + extractCallEvents, buildCallStack, buildPcToInstructionMap, type TraceStep, @@ -21,6 +25,100 @@ function instr(offset: number, context: unknown): Program.Instruction { } as unknown as Program.Instruction; } +describe("extractTransformFromInstruction", () => { + it("returns identifiers from a direct transform context", () => { + const i = instr(0, { transform: ["tailcall"] }); + expect(extractTransformFromInstruction(i)).toEqual(["tailcall"]); + }); + + it("finds transform identifiers nested inside a gather", () => { + const i = instr(0, { + gather: [ + { return: { identifier: "sum" } }, + { invoke: { jump: true, identifier: "sum" } }, + { transform: ["tailcall"] }, + ], + }); + expect(extractTransformFromInstruction(i)).toEqual(["tailcall"]); + }); + + it("collects multiple identifiers across nested contexts", () => { + const i = instr(0, { + gather: [{ transform: ["inline"] }, { transform: ["tailcall"] }], + }); + expect(extractTransformFromInstruction(i).sort()).toEqual([ + "inline", + "tailcall", + ]); + }); + + it("returns an empty array when no transform is present", () => { + const i = instr(0, { invoke: { jump: true, identifier: "sum" } }); + expect(extractTransformFromInstruction(i)).toEqual([]); + }); +}); + +describe("extractCallInfoFromInstruction tailcall flag", () => { + it("marks isTailCall when a tailcall transform is present", () => { + const i = instr(0, { + gather: [ + { return: { identifier: "sum" } }, + { invoke: { jump: true, identifier: "sum" } }, + { transform: ["tailcall"] }, + ], + }); + const info = extractCallInfoFromInstruction(i); + expect(info?.isTailCall).toBe(true); + }); + + it("leaves isTailCall falsy for a plain invoke", () => { + const i = instr(0, { invoke: { jump: true, identifier: "sum" } }); + const info = extractCallInfoFromInstruction(i); + expect(info?.isTailCall).toBeFalsy(); + }); +}); + +describe("buildCallStack TCO frame replacement", () => { + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMPDEST" }, // entry invoke → push sum + { pc: 10, opcode: "JUMP" }, // TCO back-edge → replace frame + ]; + + const program = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "sum" } }), + instr(10, { + gather: [ + { return: { identifier: "sum" } }, + { invoke: { jump: true, identifier: "sum" } }, + { transform: ["tailcall"] }, + ], + }), + ], + } as unknown as Program; + + const pcToInstruction = buildPcToInstructionMap(program); + + it("keeps the stack depth stable across a tail call", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); + // Without the fix, the return-first gather pops to empty. + expect(stack).toHaveLength(1); + }); + + it("replaces the top frame and marks it as a tail call", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack[0].identifier).toBe("sum"); + expect(stack[0].isTailCall).toBe(true); + expect(stack[0].stepIndex).toBe(1); + }); + + it("does not mark a normal (pre-tailcall) frame", () => { + const stack = buildCallStack(trace, pcToInstruction, 0); + expect(stack).toHaveLength(1); + expect(stack[0].isTailCall).toBeFalsy(); + }); +}); + describe("buildCallStack flat return+invoke back-edge", () => { // A self-recursive tail loop: // pc 0 — entry, invoke `sum` (push a frame) @@ -71,7 +169,11 @@ describe("buildCallStack flat return+invoke back-edge", () => { it("still pushes and pops ordinary (non-flat) calls", () => { // A normal invoke on one instruction, a normal return on - // another — depth should rise then fall. + // another — depth rises then falls, in contrast to the flat + // back-edge which reuses the frame in place. The pop uses + // close-after semantics: the frame stays visible while parked + // ON the return instruction and is popped only once execution + // advances past it. const normalProgram = { instructions: [ instr(0, { invoke: { jump: true, identifier: "helper" } }), @@ -80,10 +182,526 @@ describe("buildCallStack flat return+invoke back-edge", () => { } as unknown as Program; const map = buildPcToInstructionMap(normalProgram); const normalTrace: TraceStep[] = [ - { pc: 0, opcode: "JUMPDEST" }, - { pc: 8, opcode: "JUMP" }, + { pc: 0, opcode: "JUMPDEST" }, // invoke helper → push + { pc: 8, opcode: "JUMP" }, // return helper (close-after) + { pc: 12, opcode: "STOP" }, // advanced past the return ]; + // Pushed on the invoke. expect(buildCallStack(normalTrace, map, 0)).toHaveLength(1); - expect(buildCallStack(normalTrace, map, 1)).toHaveLength(0); + // Still visible while parked on the return (close-after). + expect(buildCallStack(normalTrace, map, 1)).toHaveLength(1); + // Popped once execution advances past the return. + expect(buildCallStack(normalTrace, map, 2)).toHaveLength(0); + }); +}); + +// The compiler (bugc #217) emits the TCO back-edge as a +// single FLAT context object carrying return + invoke + +// transform keys together (not a gather). This is the +// actual production shape, so it needs direct coverage. +describe("flat (production) TCO back-edge shape", () => { + const flatBackEdge = { + return: { identifier: "sum" }, + invoke: { + jump: true, + identifier: "sum", + target: { pointer: { location: "code", offset: 0, length: 1 } }, + }, + transform: ["tailcall"], + }; + + it("extracts the tailcall transform from the flat object", () => { + expect(extractTransformFromInstruction(instr(0, flatBackEdge))).toEqual([ + "tailcall", + ]); + }); + + it("marks isTailCall on the flat back-edge", () => { + const info = extractCallInfoFromInstruction(instr(0, flatBackEdge)); + expect(info?.isTailCall).toBe(true); + }); + + it("replaces the frame in place for a flat back-edge", () => { + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMPDEST" }, + { pc: 10, opcode: "JUMP" }, + ]; + const program = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "sum" } }), + instr(10, flatBackEdge), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack).toHaveLength(1); + expect(stack[0].identifier).toBe("sum"); + expect(stack[0].isTailCall).toBe(true); + expect(stack[0].callType).toBe("internal"); + }); + + it("does not label a tail call when the marker is stripped", () => { + // With the transform marker gone, the flat {return, invoke} + // back-edge is still reused in place structurally (the call + // stack stays correct for consumers that ignore transforms), + // but no frame is *labeled* isTailCall — the chip/banner + // rendering follows the `tailcall` transform marker, which + // is absent here. + const stripped = { + return: { identifier: "sum" }, + invoke: { jump: true, identifier: "sum" }, + }; + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMPDEST" }, + { pc: 10, opcode: "JUMP" }, + ]; + const program = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "sum" } }), + instr(10, stripped), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack.some((f) => f.isTailCall)).toBe(false); + }); +}); + +// Inlined internal calls (level-2 `inline` transform) produce +// VIRTUAL activations, not real ones. The compiler brackets an +// inlined body with a virtual invoke on the entry-first +// instruction and a virtual return on the exit-last instruction; +// every inlined instruction carries transform:["inline"]. The +// call stack reconstructs the virtual frame via close-after +// push/pop (a frame is visible AT its return-bearing instruction +// and popped on advance), tags it, and — belt-and-suspenders — +// tears down any trailing virtual frame the moment execution +// reaches an instruction whose inline-marker count is below the +// open virtual depth. So it reads distinctly from a real call and +// never leaks a phantom frame into caller code. +describe("inline virtual activations", () => { + const entryInvoke = { + code: { source: { id: "0" }, range: { offset: 0, length: 1 } }, + transform: ["inline"], + invoke: { jump: true, identifier: "dbl" }, + }; + const bodyMark = { + code: { source: { id: "0" }, range: { offset: 1, length: 1 } }, + transform: ["inline"], + }; + const exitReturn = { + code: { source: { id: "0" }, range: { offset: 2, length: 1 } }, + transform: ["inline"], + return: { identifier: "dbl" }, + }; + const callerMark = { + code: { source: { id: "0" }, range: { offset: 3, length: 1 } }, + }; + // A body that emits to a single EVM op: invoke and return + // co-locate on one instruction (the degenerate bracketed case). + const singleOpBody = { + code: { source: { id: "0" }, range: { offset: 0, length: 1 } }, + transform: ["inline"], + invoke: { jump: true, identifier: "dbl" }, + return: { identifier: "dbl" }, + }; + + describe("extractCallInfoFromInstruction inline flag", () => { + it("marks isInline on a virtual (inline) invoke", () => { + const info = extractCallInfoFromInstruction(instr(0, entryInvoke)); + expect(info?.kind).toBe("invoke"); + expect(info?.isInline).toBe(true); + }); + + it("marks isInline on a virtual (inline) return", () => { + const info = extractCallInfoFromInstruction(instr(0, exitReturn)); + expect(info?.kind).toBe("return"); + expect(info?.isInline).toBe(true); + }); + + it("leaves isInline falsy for a plain (real) invoke", () => { + const info = extractCallInfoFromInstruction( + instr(0, { invoke: { jump: true, identifier: "dbl" } }), + ); + expect(info?.isInline).toBeFalsy(); + }); + }); + + describe("extractCallEvents exposes both discriminators", () => { + it("returns invoke then return, in order, for a co-located context", () => { + const events = extractCallEvents(instr(0, singleOpBody)); + expect(events.map((e) => e.kind)).toEqual(["invoke", "return"]); + expect(events.every((e) => e.isInline)).toBe(true); + }); + + it("returns a single invoke event for a pure invoke", () => { + const events = extractCallEvents(instr(0, entryInvoke)); + expect(events.map((e) => e.kind)).toEqual(["invoke"]); + }); + + it("returns a single return event for a pure return", () => { + const events = extractCallEvents(instr(0, exitReturn)); + expect(events.map((e) => e.kind)).toEqual(["return"]); + }); + }); + + describe("buildCallStack virtual frame lifetime (close-after)", () => { + // A single inlined body: entry / body / exit / caller. + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // entry invoke → push virtual dbl + { pc: 1, opcode: "ADD" }, // inlined body instruction + { pc: 2, opcode: "MSTORE" }, // exit return (still inside frame) + { pc: 3, opcode: "JUMPDEST" }, // caller code (frame gone) + ]; + const program = { + instructions: [ + instr(0, entryInvoke), + instr(1, bodyMark), + instr(2, exitReturn), + instr(3, callerMark), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("pushes a virtual frame tagged isInline at the entry", () => { + const stack = buildCallStack(trace, pcToInstruction, 0); + expect(stack).toHaveLength(1); + expect(stack[0].identifier).toBe("dbl"); + expect(stack[0].isInline).toBe(true); + }); + + it("keeps the virtual frame open across the inlined body", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); + }); + + it("still shows the frame AT the exit return (close-after)", () => { + const stack = buildCallStack(trace, pcToInstruction, 2); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); + }); + + it("pops the frame once execution advances past the return", () => { + const stack = buildCallStack(trace, pcToInstruction, 3); + expect(stack).toHaveLength(0); + }); + }); + + describe("single-op inlined body (co-located invoke+return)", () => { + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // the whole body: invoke+return + { pc: 1, opcode: "JUMPDEST" }, // caller code + ]; + const program = { + instructions: [instr(0, singleOpBody), instr(1, callerMark)], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("shows the virtual frame AT the single body op", () => { + const stack = buildCallStack(trace, pcToInstruction, 0); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); + }); + + it("pops after advancing off the body op", () => { + expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(0); + }); + }); + + describe("two gap-separated inline sites of the same helper", () => { + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // site 1 entry + { pc: 2, opcode: "MSTORE" }, // site 1 exit + { pc: 3, opcode: "JUMPDEST" }, // caller gap (no inline) + { pc: 10, opcode: "PUSH1" }, // site 2 entry + { pc: 12, opcode: "MSTORE" }, // site 2 exit + { pc: 13, opcode: "JUMPDEST" }, // caller + ]; + const program = { + instructions: [ + instr(0, entryInvoke), + instr(2, exitReturn), + instr(3, callerMark), + instr(10, entryInvoke), + instr(12, exitReturn), + instr(13, callerMark), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("shows depth 1 while inside the second body", () => { + const stack = buildCallStack(trace, pcToInstruction, 3); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); + expect(stack[0].stepIndex).toBe(3); + }); + + it("is empty after both sites — no accumulation", () => { + const stack = buildCallStack(trace, pcToInstruction, 5); + expect(stack).toHaveLength(0); + }); + }); + + describe("two ADJACENT inline sites split by the return", () => { + // No caller gap between sites: the return marker (not the + // membership guard, which can't see a boundary between two + // inline-marked instructions) is what closes site 1 before + // site 2 opens. + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // site 1 entry + { pc: 1, opcode: "MSTORE" }, // site 1 exit + { pc: 2, opcode: "PUSH1" }, // site 2 entry (immediately) + { pc: 3, opcode: "MSTORE" }, // site 2 exit + { pc: 5, opcode: "JUMPDEST" }, // caller + ]; + const program = { + instructions: [ + instr(0, entryInvoke), + instr(1, exitReturn), + instr(2, entryInvoke), + instr(3, exitReturn), + instr(5, callerMark), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("does not merge or accumulate — one frame, rooted at site 2", () => { + const stack = buildCallStack(trace, pcToInstruction, 2); + expect(stack).toHaveLength(1); + expect(stack[0].stepIndex).toBe(2); + }); + + it("is empty after both sites", () => { + expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(0); + }); + }); + + describe("marker-keyed dedup", () => { + // A real call and an inlined body of the SAME name on + // consecutive steps must NOT be merged by the caller-JUMP / + // callee-JUMPDEST dedup — they are distinct activations. + const realInvoke = { invoke: { jump: true, identifier: "dbl" } }; + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMP" }, // real invoke of dbl + { pc: 1, opcode: "PUSH1" }, // virtual (inline) invoke of dbl + ]; + const program = { + instructions: [instr(0, realInvoke), instr(1, entryInvoke)], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("keeps a real and a virtual dbl as two separate frames", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack).toHaveLength(2); + expect(stack[0].isInline).toBeFalsy(); + expect(stack[1].isInline).toBe(true); + }); + }); + + describe("nested inlining (double inline marker)", () => { + // Helper A inlined into helper B which is itself inlined: + // A's body instructions are members of both bodies and carry + // transform:["inline","inline"]. Two virtual frames stack; the + // inner returns first, leaving the outer. + const entryB = { + transform: ["inline"], + invoke: { jump: true, identifier: "B" }, + }; + const entryA = { + transform: ["inline", "inline"], + invoke: { jump: true, identifier: "A" }, + }; + const exitA = { + transform: ["inline", "inline"], + return: { identifier: "A" }, + }; + const bodyB = { transform: ["inline"] }; // back to just B's body + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // enter B + { pc: 1, opcode: "PUSH1" }, // enter A (inside B) + { pc: 2, opcode: "MSTORE" }, // exit A + { pc: 3, opcode: "ADD" }, // back in B only + ]; + const program = { + instructions: [ + instr(0, entryB), + instr(1, entryA), + instr(2, exitA), + instr(3, bodyB), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("stacks two virtual frames inside the inner body", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); + expect(stack).toHaveLength(2); + expect(stack[0].identifier).toBe("B"); + expect(stack[1].identifier).toBe("A"); + }); + + it("drops to the outer frame after the inner returns", () => { + const stack = buildCallStack(trace, pcToInstruction, 3); + expect(stack).toHaveLength(1); + expect(stack[0].identifier).toBe("B"); + }); + }); + + describe("defensive membership guard", () => { + // A virtual invoke whose exit return never arrives (residual + // smear / dropped marker): the frame must still be torn down + // when execution reaches a non-inline caller instruction, + // rather than leaking to the end of the trace. + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // virtual invoke → push + { pc: 1, opcode: "ADD" }, // still inside the body + { pc: 3, opcode: "JUMPDEST" }, // caller code, no inline marker + ]; + const program = { + instructions: [ + instr(0, entryInvoke), + instr(1, bodyMark), + instr(3, callerMark), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("keeps the frame while inline membership holds", () => { + expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(1); + }); + + it("force-pops a stale virtual frame at a non-inline instr", () => { + expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(0); + }); + }); + + describe("real calls (regression: close-after applies uniformly)", () => { + // A real call: caller JUMP + callee JUMPDEST (deduped), then a + // return. The frame is visible at its return step and popped on + // advance — same close-after rule as virtual frames. + const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMP" }, // caller invoke + { pc: 1, opcode: "JUMPDEST" }, // callee entry invoke (dedup) + { pc: 2, opcode: "JUMP" }, // callee return + { pc: 3, opcode: "JUMPDEST" }, // back in caller + ]; + const program = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "f" } }), + instr(1, { invoke: { jump: true, identifier: "f" } }), + instr(2, { return: { identifier: "f" } }), + instr(3, { code: { source: { id: "0" }, range: {} } }), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("collapses the caller/callee invoke double into one frame", () => { + expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(1); + }); + + it("still shows the frame AT its return instruction", () => { + const stack = buildCallStack(trace, pcToInstruction, 2); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBeFalsy(); + }); + + it("pops the real frame on advancing past the return", () => { + expect(buildCallStack(trace, pcToInstruction, 3)).toHaveLength(0); + }); + }); + + describe("bracketed emission (post de-smear, #235 shape)", () => { + // The real bracketed shape: invoke on the body's FIRST op, + // return on its LAST op, transform:["inline"] on every op. The + // frame must be visible across the whole body — including the + // return-bearing exit op (close-after) — and gone at the gap. + const entryOp = { + transform: ["inline"], + invoke: { jump: true, identifier: "dbl" }, + }; + const interiorOp = { transform: ["inline"] }; + const exitOp = { transform: ["inline"], return: { identifier: "dbl" } }; + const gapOp = { code: { source: { id: "0" }, range: {} } }; + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // entry op (invoke) + { pc: 1, opcode: "DUP2" }, // interior op + { pc: 2, opcode: "ADD" }, // interior op + { pc: 3, opcode: "MSTORE" }, // exit op (return) + { pc: 4, opcode: "JUMPDEST" }, // gap / caller + ]; + const program = { + instructions: [ + instr(0, entryOp), + instr(1, interiorOp), + instr(2, interiorOp), + instr(3, exitOp), + instr(4, gapOp), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("shows the virtual frame across every body op incl. the exit", () => { + for (const s of [0, 1, 2, 3]) { + const stack = buildCallStack(trace, pcToInstruction, s); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); + } + }); + + it("is gone at the gap after the return op", () => { + expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(0); + }); + }); + + describe("robustness: legacy SMEARED emission (pre de-smear)", () => { + // Belt-and-suspenders: an older/residual emission where EVERY + // body op carries invoke+return+inline. Close-after must still + // yield exactly one frame per body across all ops (the viewed + // op's co-located return is deferred; prior ops net empty) and + // no accumulation across two gap-separated bodies. + const smearedOp = { + transform: ["inline"], + invoke: { jump: true, identifier: "dbl" }, + return: { identifier: "dbl" }, + }; + const gapOp = { code: { source: { id: "0" }, range: {} } }; + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // body 1: 3 smeared ops + { pc: 1, opcode: "DUP2" }, + { pc: 2, opcode: "MSTORE" }, + { pc: 3, opcode: "JUMPDEST" }, // gap + { pc: 4, opcode: "PUSH1" }, // body 2: 3 smeared ops + { pc: 5, opcode: "DUP2" }, + { pc: 6, opcode: "MSTORE" }, + { pc: 7, opcode: "JUMPDEST" }, // gap + ]; + const program = { + instructions: [ + instr(0, smearedOp), + instr(1, smearedOp), + instr(2, smearedOp), + instr(3, gapOp), + instr(4, smearedOp), + instr(5, smearedOp), + instr(6, smearedOp), + instr(7, gapOp), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("shows exactly one frame across each smeared body", () => { + for (const s of [0, 1, 2, 4, 5, 6]) { + const stack = buildCallStack(trace, pcToInstruction, s); + expect(stack).toHaveLength(1); + expect(stack[0].isInline).toBe(true); + } + }); + + it("returns to top level at each gap — no accumulation", () => { + expect(buildCallStack(trace, pcToInstruction, 3)).toHaveLength(0); + expect(buildCallStack(trace, pcToInstruction, 7)).toHaveLength(0); + }); }); }); diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 7710b724d..e4acbd505 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -2,7 +2,7 @@ * Utilities for creating mock execution traces. */ -import type { Program } from "@ethdebug/format"; +import { Program } from "@ethdebug/format"; /** * A single step in an execution trace. @@ -119,108 +119,188 @@ export interface CallInfo { label: string; pointer: unknown; }>; + /** + * True when a `tailcall` transform is present on the same + * instruction — the call was realized as a tail-call + * (TCO), reusing the current frame rather than nesting. + */ + isTailCall?: boolean; + /** + * True when an `inline` transform is present on the same + * instruction — this invoke/return belongs to an inlined + * (virtual) activation, not a real call. + */ + isInline?: boolean; } /** - * Extract call info (invoke/return/revert) from an - * instruction's context tree. + * Extract compiler `transform` annotation identifiers + * (e.g. "tailcall", "inline") from an instruction's context + * tree, walking gather/pick composites. + */ +export function extractTransformFromInstruction( + instruction: Program.Instruction, +): string[] { + if (!instruction.context) { + return []; + } + return extractTransformFromContext(instruction.context); +} + +function extractTransformFromContext(context: Program.Context): string[] { + if (Program.Context.isTransform(context)) { + return context.transform; + } + + // gather/pick are still key-probed here, matching the + // sibling extractors in this file (a broader guard + // migration is tracked separately). + const ctx = context as unknown as Record; + + if ("gather" in ctx && Array.isArray(ctx.gather)) { + return (ctx.gather as Program.Context[]).flatMap( + extractTransformFromContext, + ); + } + + if ("pick" in ctx && Array.isArray(ctx.pick)) { + return (ctx.pick as Program.Context[]).flatMap(extractTransformFromContext); + } + + return []; +} + +/** + * Extract the primary call event (invoke/return/revert) from an + * instruction's context tree, decorated with transform flags. + * + * A context can legitimately carry BOTH an invoke and a return + * (e.g. a tail-call back-edge, or an inlined body that emits to a + * single instruction). This accessor returns just the first event + * for display banners; call-stack reconstruction uses + * {@link extractCallEvents}, which surfaces every event so a + * co-located return is never swallowed by the invoke. */ export function extractCallInfoFromInstruction( instruction: Program.Instruction, ): CallInfo | undefined { + return extractCallEvents(instruction)[0]; +} + +/** + * Extract ALL call events (invoke/return/revert) from an + * instruction's context tree, in document order (invoke before + * return within one context), decorated with the instruction's + * transform flags. Returns [] when there is no call context. + */ +export function extractCallEvents( + instruction: Program.Instruction, +): CallInfo[] { if (!instruction.context) { - return undefined; + return []; + } + const events = collectCallInfos(instruction.context); + if (events.length === 0) { + return []; } - return extractCallInfoFromContext(instruction.context); + const transforms = extractTransformFromContext(instruction.context); + const isTailCall = transforms.includes("tailcall"); + const isInline = transforms.includes("inline"); + if (!isTailCall && !isInline) { + return events; + } + return events.map((e) => ({ + ...e, + ...(isTailCall ? { isTailCall: true } : {}), + ...(isInline ? { isInline: true } : {}), + })); } -function extractCallInfoFromContext( - context: Program.Context, -): CallInfo | undefined { +/** + * Collect the invoke/return/revert events carried by a context + * tree, in order. Invoke precedes return within a single context; + * gather/pick children are visited in sequence. + */ +function collectCallInfos(context: Program.Context): CallInfo[] { // Use unknown intermediate to avoid strict type checks // on the context union — we discriminate by key presence const ctx = context as unknown as Record; + const out: CallInfo[] = []; if ("invoke" in ctx) { - const inv = ctx.invoke as Record; - const pointerRefs: CallInfo["pointerRefs"] = []; - - let callType: CallInfo["callType"]; - if ("jump" in inv) { - callType = "internal"; - collectPointerRef(pointerRefs, "target", inv.target); - collectPointerRef(pointerRefs, "arguments", inv.arguments); - } else if ("message" in inv) { - callType = "external"; - collectPointerRef(pointerRefs, "target", inv.target); - collectPointerRef(pointerRefs, "gas", inv.gas); - collectPointerRef(pointerRefs, "value", inv.value); - collectPointerRef(pointerRefs, "input", inv.input); - } else if ("create" in inv) { - callType = "create"; - collectPointerRef(pointerRefs, "value", inv.value); - collectPointerRef(pointerRefs, "salt", inv.salt); - collectPointerRef(pointerRefs, "input", inv.input); - } - - // Extract argument names from group entries - const argNames = extractArgNamesFromInvoke(inv); - - return { - kind: "invoke", - identifier: inv.identifier as string | undefined, - callType, - argumentNames: argNames, - pointerRefs, - }; + out.push(parseInvoke(ctx.invoke as Record)); } - if ("return" in ctx) { - const ret = ctx.return as Record; - const pointerRefs: CallInfo["pointerRefs"] = []; - collectPointerRef(pointerRefs, "data", ret.data); - collectPointerRef(pointerRefs, "success", ret.success); - - return { - kind: "return", - identifier: ret.identifier as string | undefined, - pointerRefs, - }; + out.push(parseReturn(ctx.return as Record)); } - if ("revert" in ctx) { - const rev = ctx.revert as Record; - const pointerRefs: CallInfo["pointerRefs"] = []; - collectPointerRef(pointerRefs, "reason", rev.reason); - - return { - kind: "revert", - identifier: rev.identifier as string | undefined, - panic: rev.panic as number | undefined, - pointerRefs, - }; + out.push(parseRevert(ctx.revert as Record)); } - // Walk gather/pick to find call info - if ("gather" in ctx && Array.isArray(ctx.gather)) { + if (Array.isArray(ctx.gather)) { for (const sub of ctx.gather as Program.Context[]) { - const info = extractCallInfoFromContext(sub); - if (info) { - return info; - } + out.push(...collectCallInfos(sub)); } } - - if ("pick" in ctx && Array.isArray(ctx.pick)) { + if (Array.isArray(ctx.pick)) { for (const sub of ctx.pick as Program.Context[]) { - const info = extractCallInfoFromContext(sub); - if (info) { - return info; - } + out.push(...collectCallInfos(sub)); } } - return undefined; + return out; +} + +function parseInvoke(inv: Record): CallInfo { + const pointerRefs: CallInfo["pointerRefs"] = []; + + let callType: CallInfo["callType"]; + if ("jump" in inv) { + callType = "internal"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "arguments", inv.arguments); + } else if ("message" in inv) { + callType = "external"; + collectPointerRef(pointerRefs, "target", inv.target); + collectPointerRef(pointerRefs, "gas", inv.gas); + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "input", inv.input); + } else if ("create" in inv) { + callType = "create"; + collectPointerRef(pointerRefs, "value", inv.value); + collectPointerRef(pointerRefs, "salt", inv.salt); + collectPointerRef(pointerRefs, "input", inv.input); + } + + return { + kind: "invoke", + identifier: inv.identifier as string | undefined, + callType, + argumentNames: extractArgNamesFromInvoke(inv), + pointerRefs, + }; +} + +function parseReturn(ret: Record): CallInfo { + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "data", ret.data); + collectPointerRef(pointerRefs, "success", ret.success); + return { + kind: "return", + identifier: ret.identifier as string | undefined, + pointerRefs, + }; +} + +function parseRevert(rev: Record): CallInfo { + const pointerRefs: CallInfo["pointerRefs"] = []; + collectPointerRef(pointerRefs, "reason", rev.reason); + return { + kind: "revert", + identifier: rev.identifier as string | undefined, + panic: rev.panic as number | undefined, + pointerRefs, + }; } function extractArgNamesFromInvoke( @@ -274,6 +354,18 @@ export interface CallFrame { argumentNames?: string[]; /** Individual argument pointers for value resolution */ argumentPointers?: unknown[]; + /** + * True when this frame was (re)entered via a tail call + * (TCO). The frame was reused in place rather than nested. + */ + isTailCall?: boolean; + /** + * True when this frame is a VIRTUAL activation reconstructed + * from an inlined body (transform:["inline"]) rather than a + * real call. Its instructions were spliced into the caller; + * no JUMP occurred. + */ + isInline?: boolean; } /** @@ -294,29 +386,42 @@ export function buildCallStack( continue; } - const callInfo = extractCallInfoFromInstruction(instruction); - if (!callInfo) { - continue; - } + // Per-instruction inline membership drives the defensive + // guard below: an inlined body's instructions all carry + // transform:["inline"] (nested inlining stacks the marker), so + // the count bounds how many virtual frames may legitimately be + // open on this instruction. + const transforms = extractTransformFromInstruction(instruction); + const inlineCount = transforms.filter((t) => t === "inline").length; // A tail-call back-edge carries both a `return` (the previous // iteration) and an `invoke` (the next iteration) on a single // instruction. The activation is reused, not nested or // unwound, so depth is unchanged: replace the top frame in // place rather than pushing a second frame or popping it away. - // Identity comes from the invoke leaf. + // Identity comes from the invoke leaf. Detection is structural + // (return + invoke together), so the call stack stays correct + // even for consumers that ignore transforms — but an inlined + // single-instruction body ALSO carries both; it is a virtual + // activation handled by the event loop below, so exclude the + // inline marker here. The isTailCall *label* (which drives the + // call-stack chip / info banner) follows the `tailcall` marker. const ctx = instruction.context as Record | undefined; const backEdgeInvoke = ctx ? findInvokeField(ctx) : undefined; - if (ctx && backEdgeInvoke && hasReturnContext(ctx)) { + if ( + ctx && + backEdgeInvoke && + hasReturnContext(ctx) && + !transforms.includes("inline") + ) { const argResult = extractArgInfo(instruction); const frame: CallFrame = { - identifier: - (backEdgeInvoke.identifier as string | undefined) ?? - callInfo.identifier, + identifier: backEdgeInvoke.identifier as string | undefined, stepIndex: i, callType: invokeCallType(backEdgeInvoke), argumentNames: argResult?.names, argumentPointers: argResult?.pointers, + isTailCall: transforms.includes("tailcall"), }; if (stack.length > 0) { stack[stack.length - 1] = frame; @@ -326,44 +431,72 @@ export function buildCallStack( continue; } - if (callInfo.kind === "invoke") { - // The compiler emits invoke on both the caller JUMP - // and callee entry JUMPDEST for the same call. These - // occur on consecutive trace steps. Only skip if the - // top frame matches AND was pushed on the immediately - // preceding step — otherwise this is a new call (e.g. - // recursion with the same function name). - const top = stack[stack.length - 1]; - const isDuplicate = - top && - top.identifier === callInfo.identifier && - top.callType === callInfo.callType && - top.stepIndex === i - 1; - if (isDuplicate) { - // Use the callee entry step for resolution — - // the argument pointers reference stack slots - // that are valid at the JUMPDEST, not the JUMP. - // Argument names also live on the callee entry. - const argResult = extractArgInfo(instruction); - top.stepIndex = i; - top.argumentNames = argResult?.names ?? top.argumentNames; - top.argumentPointers = argResult?.pointers; - } else { - const argResult = extractArgInfo(instruction); - stack.push({ - identifier: callInfo.identifier, - stepIndex: i, - callType: callInfo.callType, - argumentNames: argResult?.names, - argumentPointers: argResult?.pointers, - }); - } - } else if (callInfo.kind === "return" || callInfo.kind === "revert") { - // Pop the matching frame - if (stack.length > 0) { - stack.pop(); + // A context may carry more than one event (invoke + return), + // e.g. an inlined body that emits to a single instruction. + // Process them in order: an invoke opens a frame INCLUSIVE of + // its instruction; a return closes it AFTER its instruction + // (close-after) — so the frame is still shown while parked on + // the return-bearing instruction and popped only on advance. + for (const event of extractCallEvents(instruction)) { + if (event.kind === "invoke") { + // The compiler emits invoke on both the caller JUMP and + // callee entry JUMPDEST for a REAL call, on consecutive + // steps — collapse that double. Key the dedup on the + // inline marker so a virtual invoke never merges with an + // adjacent real invoke of the same name (and vice versa). + const top = stack[stack.length - 1]; + const isDuplicate = + top && + top.identifier === event.identifier && + top.callType === event.callType && + top.stepIndex === i - 1 && + !!top.isInline === !!event.isInline; + if (isDuplicate) { + // Use the callee entry step for resolution — argument + // pointers/names live on the JUMPDEST, not the JUMP. + const argResult = extractArgInfo(instruction); + top.stepIndex = i; + top.argumentNames = argResult?.names ?? top.argumentNames; + top.argumentPointers = argResult?.pointers; + } else { + const argResult = extractArgInfo(instruction); + stack.push({ + identifier: event.identifier, + stepIndex: i, + callType: event.callType, + argumentNames: argResult?.names, + argumentPointers: argResult?.pointers, + // Tag virtual activations so the widget can render + // them distinctly from real calls. + ...(event.isInline ? { isInline: true } : {}), + }); + } + } else if (event.kind === "return" || event.kind === "revert") { + // close-after: defer the pop until we advance past this + // step, so the frame is visible AT its return instruction. + if (i < upToStep && stack.length > 0) { + stack.pop(); + } } } + + // Defensive membership guard: virtual frames beyond the + // instruction's inline-marker count are stale — belt-and- + // suspenders against a dropped or incomplete virtual return so + // a phantom activation can never leak into caller code (or + // linger after an inner inlined body has ended). + let trailingVirtual = 0; + for (let k = stack.length - 1; k >= 0 && stack[k].isInline; k--) { + trailingVirtual++; + } + while ( + trailingVirtual > inlineCount && + stack.length > 0 && + stack[stack.length - 1].isInline + ) { + stack.pop(); + trailingVirtual--; + } } return stack; diff --git a/packages/web/docs/core-schemas/programs/tracing-examples.ts b/packages/web/docs/core-schemas/programs/tracing-examples.ts index 08cb5f8df..24fecdfec 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -96,3 +96,69 @@ create { code { result = isEven(4); }`; + +export const tailRecursiveSum = `name TailSum; + +define { + function sum(n: uint256, acc: uint256) -> uint256 { + if (n == 0) { return acc; } + else { return sum(n - 1, acc + n); } + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = sum(5, 0); +}`; + +export const tailRecursiveFactorial = `name TailFactorial; + +define { + function fact(n: uint256, acc: uint256) -> uint256 { + if (n == 0) { return acc; } + else { return fact(n - 1, acc * n); } + }; +} + +storage { + [0] result: uint256; +} + +create { + result = 0; +} + +code { + result = fact(5, 1); +}`; + +export const inlineDemo = `name InlineDemo; + +define { + function square(x: uint256) -> uint256 { + return x * x; + }; +} + +storage { + [0] a: uint256; + [1] b: uint256; + [2] sumOfSquares: uint256; +} + +create { + a = 3; + b = 4; + sumOfSquares = 0; +} + +code { + sumOfSquares = square(a) + square(b); +}`; diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index 6c680752f..67e2e234a 100644 --- a/packages/web/docs/core-schemas/programs/tracing.mdx +++ b/packages/web/docs/core-schemas/programs/tracing.mdx @@ -173,6 +173,49 @@ instead of (or alongside) a reason pointer: }`} +## Optimized code: the tailcall transform + +When the compiler optimizes tail-recursive calls, it turns the recursion +into a loop: the recursive call becomes a **back-edge** — a single JUMP that +ends one iteration and begins the next without pushing a frame. That one +JUMP carries three facts at once, composed as sibling keys on a single +context (the flat form described on the +[transform context](/spec/program/context/transform) page): + + + {`{ + "return": { + "identifier": "sum" + }, + "invoke": { + "jump": true, + "identifier": "sum", + "target": { + "pointer": { "location": "code", "offset": "0x33", "length": 1 } + } + }, + "transform": ["tailcall"] +}`} + + +The `return` and `invoke` state the source-level facts — the previous +iteration returned, the next was invoked — and `transform: ["tailcall"]` +explains how the compiler realized that pair as one JUMP. Because no value +crosses a frame boundary here, the `return` carries no `data` and the +`invoke` no `arguments`: the accumulator is threaded through the loop +directly, and the invoke `target` points at the loop header the JUMP +re-enters. A debugger that ignores the transform still reads a coherent +invoke/return sequence; one that understands it can show that the call stack +isn't really growing. + +To watch this happen — flipping the optimizer between **O0** and **O2** and +stepping through the back-edge — see the tail-call optimization examples in +the [Trace playground](/docs/explore/trace-playground). + ## Trace data structure A trace step captures the EVM state at a single point: diff --git a/packages/web/docs/explore/index.mdx b/packages/web/docs/explore/index.mdx index 867c4977d..4d1ffd515 100644 --- a/packages/web/docs/explore/index.mdx +++ b/packages/web/docs/explore/index.mdx @@ -16,7 +16,8 @@ your browser; no setup required. storage slot, a struct, and a computed location each select their bytes. - [**Trace playground**](/docs/explore/trace-playground) — Compile a BUG program and step through its execution trace: source location, in-scope - variables, the call stack, and instruction context at every step. + variables, the call stack, and instruction context at every step — + including the optimizer's tail-call transform. - [**BUG playground**](/docs/explore/bug-playground) — Compile the BUG language end to end and inspect each stage: AST, IR, control-flow graph, and bytecode. diff --git a/packages/web/docs/explore/trace-playground.mdx b/packages/web/docs/explore/trace-playground.mdx index fda4c7e24..b74db5337 100644 --- a/packages/web/docs/explore/trace-playground.mdx +++ b/packages/web/docs/explore/trace-playground.mdx @@ -8,6 +8,8 @@ import { TracePlayground, TraceExample } from "@theme/ProgramExample"; import { counterIncrement, functionCallAndReturn, + tailRecursiveFactorial, + inlineDemo, } from "../core-schemas/programs/tracing-examples"; # Trace playground @@ -18,8 +20,8 @@ source line, in-scope variables, call stack, and instruction context light up at every step. Click **"Try it"** on an example to open the trace drawer, then use the -step controls to walk through execution. The two examples build on each -other: start with the storage write, then follow a function call. +step controls to walk through execution. The examples build on each +other: start at the top and work down, picking up one idea at a time. @@ -51,4 +53,46 @@ For the exact shape of invoke, return, and revert contexts, see the [function call spec](/spec/program/context/function) and the [tracing reference](/docs/core-schemas/programs/tracing). +## Watching the optimizer + +Compilers rewrite code as they optimize, and **transform** contexts +record what they did. Set the **Opt** selector to **O2** and step through +the examples below: each instruction the optimizer rewrote carries a +`transform` array naming the passes responsible. + +### Tail-call optimization + +In `TailFactorial` the recursive call folds into a loop. The back-edge +JUMP carries `transform: ["tailcall"]` next to its invoke and return, and +the call stack stays flat instead of growing one frame per iteration. + + + +### Inlining + +In `InlineDemo` the leaf helper `square` is small enough that at **O2** +the compiler splices its body straight into each call site. +`sumOfSquares = square(a) + square(b)` calls it twice, and after inlining +neither call JUMPs into `square` — each multiply runs inline, and those +instructions carry `transform: ["inline"]` pointing back to the `square` +body they came from. The call stack marks the inlined `square` as a +virtual activation, not a real pushed frame. + + + +Flip the **Opt** selector between **O0** and **O2** to see the difference: +at **O0** each program calls its helper the ordinary way; at **O2** the +transforms above take over. For how these transforms compose with +invoke/return, see the +[transform context spec](/spec/program/context/transform) and the +[tail-call optimization walkthrough](/docs/core-schemas/programs/tracing). + diff --git a/packages/web/spec/program/context/function/invoke.mdx b/packages/web/spec/program/context/function/invoke.mdx index 617a2b5d8..b04f44b2d 100644 --- a/packages/web/spec/program/context/function/invoke.mdx +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -56,13 +56,44 @@ meaningful code pointer to record — most notably at the first instruction of an inlined function body, where the inlining pass has elided the JUMP that would normally carry the target. The callee identity (`identifier`, `declaration`, `type`) remains -meaningful in this case. +meaningful in this case; a sibling `transform: ["inline"]` key +on the same context indicates that the call was inlined rather +than physically invoked. +### Inlined internal calls + +When the compiler inlines a callee, there is no JUMP and no +runtime activation record: the callee's instructions are spliced +directly into the caller. The call still happened at the source +level, so it is still marked with an invoke context — one that +describes the _kind_ of call without a physical target: + +- `jump: true` marks the invocation as an **internal call kind** + (as opposed to a message call or contract creation). It does + **not** assert that a JUMP instruction executes here — an + inlined call has none. +- `target` is omitted: there is no code location to point at, + because the JUMP that would carry it was elided. +- a sibling `transform: ["inline"]` key marks the instruction as + belonging to an inlined body. + +The callee identity (`identifier`, `declaration`, `type`, all +optional) is preserved, so the inlined function still appears on +the debugger's call stack: the debugger reconstructs a **virtual +activation** for it (see +[Reconstructing activations](#reconstructing-activations)). + +Compilers typically inline small or leaf non-recursive callees at +every call site, so the _same_ callee can produce several +independent virtual activations across a trace — one per inlined +site. (The precise eligibility rule is a compiler choice; the +format is the same however inlining decisions are made.) + ## External call An external call represents a call to another contract via CALL, @@ -84,3 +115,133 @@ presence of `salt` implies CREATE2. schema={{ id: "schema:ethdebug/format/program/context/function/invoke" }} pointer="#/$defs/ContractCreation" /> + +## Reconstructing activations + +A debugger reconstructs the logical call stack from `invoke` and +`return` contexts. Each entry on that stack is an **activation** +(the DWARF term for a call-stack entry). Activation handling is +uniform whether or not the call was inlined: + +- **Push** an activation when an `invoke` context is encountered + and **pop** it when the matching `return` context is + encountered, in trace order. The `invoke` opens the activation + inclusive of its instruction; the `return` closes it after its + instruction, so the instruction bearing `return` is still inside + the activation. + +Because push/pop is driven by where the `invoke` and `return` +contexts sit, a compiler must emit them as a **bracket**: the +`invoke` on the first instruction of the body and the `return` on +its last. A compiler must **not** duplicate `invoke` or `return` +across a body's interior instructions — repeating them would push +or pop spurious activations. The sole exception is a body that +compiles to a **single instruction**, whose entry and exit +coincide: that one instruction legitimately carries both `invoke` +and `return`, and a debugger processes them in order (push, then +pop). This bracket rule is what keeps the guarantee below — +that a debugger ignoring `transform` still sees a coherent +`invoke`/`return` pair — true for inlined calls. + +An inlined callee therefore appears on the call stack exactly as a +non-inlined one does. Two kinds of activation differ only in how +they are backed, distinguished by the presence of an `inline` +transform marker — **not** by whether `target` is present: + +- A **real activation** comes from an `invoke` **without** an + `inline` transform marker. It corresponds to an actual call at + runtime, corroborated by machine state — a return address on the + EVM stack — and occupies a real stack region. +- A **virtual activation** comes from an `invoke` whose context + carries `transform: ["inline"]` (an `inline` identifier in its + transform list). It has **no runtime corroboration** and occupies + no EVM stack region; it exists only in the debug annotations. Its + `target` is typically omitted (the JUMP was elided), but + `target`-absence is not itself the signal — a real internal call + may also omit `target` (see [Internal call](#internal-call)). The + reliable discriminator is the `inline` marker. + +### Activation membership + +Push/pop and membership answer two different questions. Push/pop +(above) determines **when** a virtual activation is open — its +lifetime on the call stack. Membership determines **which** open +activation a given instruction belongs to. The two are +independent, and a debugger uses both. + +An instruction belongs to the innermost open virtual activation if +and only if its context carries an `inline` identifier in its +transform list — so composed markers such as `["inline", "fold"]` +still confer membership. The nesting depth is the number of +`"inline"` occurrences in the list (doubly-inlined code carries +`["inline", "inline"]`). Membership is determined +per-instruction from this marker, **not** from instruction ranges: +optimization passes may relocate or interleave an inlined body, so +a positional "everything between the invoke and the return" rule +would be unsound. + +This is why membership is separate from lifetime. An activation +opened by an `invoke` stays open until its `return`, even across +instructions that are **not** its members — for example, caller +code an optimizer interleaved into the body's trace span. Such a +non-member instruction (no `inline` marker for that depth) is +attributed to the enclosing activation, not the inlined one, even +while the virtual activation remains on the stack. + +### Correlating with `activation` + +The push/pop rule above reconstructs the call stack without any +correlation identifier: it relies on `invoke`/`return` appearing +in a well-nested order. That is sufficient for typical compiler +output, but it has a blind spot — push/pop alone can't tell one +activation from another of the same function, so two inlined +copies of a callee placed back-to-back, or an optimizer that moves +a `return` ahead of its `invoke`, can defeat strict pairing. + +An **`activation`** identifier closes it. The `invoke` object that +opens an activation and the `return` (or `revert`) that closes it +carry the same `activation` value; distinct activations carry +distinct values. Pairing is then explicit and order-independent — +matching values pair an invoke with its return regardless of trace +order, and adjacent same-function activations stay distinct because +their identifiers differ. + +Unlike function [identity](#identity-and-values) (which function +this is), `activation` identifies _which activation instance_ this +is, so it lives **inside** the `invoke`/`return`/`revert` object +rather than as a sibling context. Two activation facts on one instruction +— a tail call's back-edge, which both returns from one activation +and invokes the next — therefore stay flat, each fact carrying its +own `activation` value, with no `gather` required. + +When `activation` is present it is **authoritative** for pairing — +which `invoke` matches which `return`. Push/pop remains the fallback +an identifier-less debugger uses; in well-nested output the two +agree. Membership — which instructions belong to a virtual +activation — is unaffected: it stays the per-instruction +[`inline` marker](#activation-membership). Attributing a body that +an optimizer relocated to a _specific_ activation would need a +membership-level identifier; that is out of scope here. + +### Identity and values + +Every function-identity field (`identifier`, `declaration`, +`type`) is optional, so a virtual activation degrades gracefully — +from full identity down to an anonymous inlined frame — with no +fabricated data. A debugger renders whatever is present. + +An inlining compiler typically preserves the callee's declaration +and per-instruction source ranges for a virtual activation, and can +resolve inlined locals that it homed in addressable memory, via +[`variables`](/spec/program/context/variables) contexts. Identity +fields remain optional and degrade gracefully as described above. +Such a compiler does **not** emit `invoke.arguments` or +`return.data` pointers; individual parameter +values may still be inspectable as locals inside the body where +they are memory-homed. A virtual activation with no resolvable +values is still a valid, displayable frame. + +A debugger that ignores `transform` contexts still sees a coherent +`invoke`/`return` pair and a sound source-level call stack. One +that understands them can present virtual activations distinctly — +for example, collapsible and tied to the callee's source location. diff --git a/packages/web/spec/program/context/gather.mdx b/packages/web/spec/program/context/gather.mdx index 72d4c6a36..3e3a90b59 100644 --- a/packages/web/spec/program/context/gather.mdx +++ b/packages/web/spec/program/context/gather.mdx @@ -19,8 +19,9 @@ single object. The context schema is open: a single context object may carry any number of discriminator keys together — `code`, `variables`, -`invoke`, `return`, and so on all compose as siblings on the same -object. Prefer the flat form when it works. +`invoke`, `return`, `transform`, and so on all compose as +siblings on the same object. Prefer the flat form when it +works. Reach for `gather` only when two or more facts would collide on the same key. The canonical cases are: diff --git a/packages/web/spec/program/context/transform.mdx b/packages/web/spec/program/context/transform.mdx new file mode 100644 index 000000000..b9070907f --- /dev/null +++ b/packages/web/spec/program/context/transform.mdx @@ -0,0 +1,141 @@ +--- +sidebar_position: 8 +--- + +import SchemaViewer from "@site/src/components/SchemaViewer"; + +# Transform contexts + +A transform context annotates an instruction with the compiler +transformations that produced it. The value is a list of short +identifiers; the list may repeat the same identifier when the +transformation has been applied multiple times—for example, +doubly-inlined code carries `transform: ["inline", "inline"]`. + + + +## Role: additional annotation + +A transform context does not replace semantic contexts. When the +compiler inlines a function, the caller's debug info should still +carry invoke/return contexts naming the inlined callee at the +call boundary—so the debugger's logical call stack reflects the +source-level structure. The transform context is _additional_ +information telling the debugger **how** the call was realized. + +Consumers are free to ignore transform contexts entirely; the +invoke/return contexts alone always give a sound source-level +view. Consumers that understand transform contexts can offer +optimization-aware presentations: + +- Render inlined code as a collapsible block tied to the + original callee's source location. +- Show which call sites were tail-call-optimized vs. realized as + full call/return sequences. +- Explain apparent anomalies in the trace (e.g., a JUMP that + carries an invoke context is a TCO back-edge). + +## Identifiers + +The schema defines four identifiers: + +- **`"inline"`** — the marked instruction is part of an inlined + function body. A surrounding `invoke`/`return` pair names the + inlined callee, and a debugger reconstructs a _virtual + activation_ for it (see + [inlined internal calls](/spec/program/context/function/invoke#inlined-internal-calls)). + This marker tells the debugger the physical code has no separate + runtime activation record. +- **`"tailcall"`** — the marked instruction is a + tail-call-optimized back-edge JUMP or continuation, where the + call was realized without pushing/popping a full activation. + A JUMP carrying a `tailcall` transform typically sits on a + context that also carries both a `return` (from the previous + iteration) and an `invoke` (of the new iteration). +- **`"fold"`** — the marked instruction carries the result of + a compile-time constant fold. Typically a PUSH of the folded + value replacing a compute sequence (e.g., `ADD` over two + known constants) that appeared in source. The instruction's + surrounding `code` context, if present, points to the + original expression. +- **`"coalesce"`** — the marked instruction is part of a + read-write merging sequence the compiler introduced to + combine adjacent source-level reads or writes. Common + examples include SHL/OR sequences that pack narrower fields + into a single storage slot, or wider loads split into + narrower field extractions. The user did not write these + instructions directly; the `coalesce` marker lets a debugger + present the sequence as one source-level operation rather + than stepping through each byte-shuffling opcode. + +The identifier set is extensible. Compilers may emit additional +identifiers for optimizations not yet standardized; debuggers +should preserve unfamiliar identifiers as opaque labels rather +than rejecting them. + +## Repetition and composition + +Identifiers may repeat. A function inlined into another inlined +function produces `transform: ["inline", "inline"]`. A coalesce +sequence nested inside another coalesced region produces +`transform: ["coalesce", "coalesce"]`. + +Different transformations compose: +`transform: ["inline", "tailcall"]` marks an instruction inside +an inlined body that was itself a TCO back-edge in the callee; +`transform: ["inline", "fold"]` marks a constant-folded PUSH +sitting inside an inlined body. + +Order in the array is not semantically significant—only the +multiset of identifiers matters. + +## Composing with other contexts + +A context object can carry several discriminator keys at once — +`code`, `variables`, `invoke`, `return`, `transform`, and so on +all live in the same object. A TCO back-edge JUMP, for example, +typically combines three facts as sibling keys on a single +context: + +```yaml +return: + identifier: "fact" + declaration: { ... } +invoke: + jump: true + identifier: "fact" + target: { pointer: { location: code, offset: ... } } +transform: ["tailcall"] +``` + +The `return` and `invoke` state the source-level facts +(iteration N returned, iteration N+1 was invoked); the +`transform` explains how the compiler realized that pair as a +single JUMP. + +An inlined call site combines an invoke with an inline transform. +The invoke marks the call kind with `jump: true` but omits +`target`, because the JUMP was elided: + +```yaml +invoke: + jump: true + identifier: "square" + declaration: { ... } +transform: ["inline"] +``` + +Each instruction of the inlined body also carries +`transform: ["inline"]`, and a matching `return` closes the +[virtual activation](/spec/program/context/function/invoke#reconstructing-activations). +A small helper inlined at several call sites produces one such +`invoke`/`return` pair — and one virtual activation — per site. + +Reach for [`gather`](/spec/program/context/gather) only when +two contexts would collide on the same key — e.g., two +independent `variables` blocks or two +[`frame`](/spec/program/context/frame)s from different +pipeline stages. When keys don't collide, the flat form is +preferred. diff --git a/packages/web/src/schemas.ts b/packages/web/src/schemas.ts index 84ba8c701..31496043a 100644 --- a/packages/web/src/schemas.ts +++ b/packages/web/src/schemas.ts @@ -228,7 +228,16 @@ const programSchemaIndex: SchemaIndex = { href: "/spec/program/context", }, - ...["name", "code", "variables", "remark", "pick", "gather", "frame"] + ...[ + "name", + "code", + "variables", + "remark", + "pick", + "gather", + "frame", + "transform", + ] .map((name) => ({ [`schema:ethdebug/format/program/context/${name}`]: { href: `/spec/program/context/${name}`, diff --git a/packages/web/src/theme/ProgramExample/CallInfoPanel.css b/packages/web/src/theme/ProgramExample/CallInfoPanel.css index 75cd06511..b2835861c 100644 --- a/packages/web/src/theme/ProgramExample/CallInfoPanel.css +++ b/packages/web/src/theme/ProgramExample/CallInfoPanel.css @@ -27,6 +27,12 @@ border-left: 3px solid var(--programs-revert-accent, #cf222e); } +.call-info-banner-tailcall { + background: var(--programs-transform-bg, #f3ecff); + color: var(--programs-transform-text, #8250df); + border-left: 3px solid var(--programs-transform-accent, #a475f9); +} + .call-info-refs { display: flex; flex-direction: column; diff --git a/packages/web/src/theme/ProgramExample/CallStackDisplay.css b/packages/web/src/theme/ProgramExample/CallStackDisplay.css index 9143b8d76..90afee044 100644 --- a/packages/web/src/theme/ProgramExample/CallStackDisplay.css +++ b/packages/web/src/theme/ProgramExample/CallStackDisplay.css @@ -48,3 +48,15 @@ .call-stack-parens { color: var(--programs-text-muted, #888); } + +.call-stack-tailcall { + margin-left: 4px; + padding: 0 5px; + border-radius: 8px; + font-size: 0.8em; + font-weight: 500; + white-space: nowrap; + background: var(--programs-transform-bg, #f3ecff); + color: var(--programs-transform-text, #8250df); + border: 1px solid var(--programs-transform-accent, #a475f9); +} diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.css b/packages/web/src/theme/ProgramExample/TraceDrawer.css index 95cc0f769..71b749b67 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.css +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.css @@ -28,6 +28,61 @@ overflow: hidden; } +/* Header actions */ +.trace-drawer-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.opt-level-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px 2px 8px; + border-radius: 6px; + background: var(--ifm-color-emphasis-100); +} + +.opt-level-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ifm-color-content-secondary); + margin-right: 2px; +} + +.opt-level-btn { + padding: 2px 8px; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 4px; + background: var(--ifm-background-color); + color: var(--ifm-color-content-secondary); + font-size: 12px; + font-weight: 600; + font-family: var(--ifm-font-family-monospace); + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.opt-level-btn:hover:not(:disabled) { + background: var(--ifm-color-emphasis-200); +} + +.opt-level-btn.active { + background: var(--ifm-color-primary); + border-color: var(--ifm-color-primary); + color: white; +} + +.opt-level-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Header buttons */ .trace-drawer-btn { padding: 6px 14px; @@ -207,6 +262,42 @@ border-left: 3px solid var(--ifm-color-danger); } +/* Transform / tail-call accent (no ifm purple semantic, so + a theme-tolerant purple tint with content-colored text). */ +.call-info-tailcall { + background: rgba(130, 80, 223, 0.12); + color: var(--ifm-color-content); + border-left: 3px solid #8250df; +} + +/* Inline boundary banner: same purple tint, dashed edge to echo the + virtual-activation badge (no real call occurred). */ +.call-info-inline { + background: rgba(130, 80, 223, 0.12); + color: var(--ifm-color-content); + border-left: 3px dashed #8250df; +} + +.call-stack-tailcall, +.call-stack-inline { + margin-left: 4px; + padding: 0 5px; + border-radius: 8px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + background: rgba(130, 80, 223, 0.15); + color: var(--ifm-color-content); + border: 1px solid rgba(130, 80, 223, 0.45); +} + +/* Virtual (inline) activations read as "not a real frame": dashed + border + italic, sharing the transform purple tint. */ +.call-stack-inline { + border-style: dashed; + font-style: italic; +} + /* Trace panels - this row grows to fill the drawer's height */ .trace-panels { display: grid; @@ -225,6 +316,9 @@ .trace-panel { background: var(--ifm-background-color); + /* min-height:0 lets the grid item shrink so its own + overflow scrolls instead of expanding the grid. */ + min-height: 0; overflow: auto; min-height: 0; } @@ -288,6 +382,72 @@ color: var(--ifm-color-primary-darkest); } +.opcode-gas { + margin-left: auto; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--ifm-color-content-secondary); +} + +/* Collapsible right-column sections */ +.trace-section { + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.trace-section-summary { + padding: 6px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ifm-color-content-secondary); + background: var(--ifm-background-surface-color); + cursor: pointer; + user-select: none; + list-style-position: inside; +} + +.trace-section-summary:hover { + color: var(--ifm-color-content); +} + +/* Transform annotations */ +.transform-list { + padding: 6px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.transform-item { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 12px; +} + +.transform-tag { + flex-shrink: 0; + padding: 0 6px; + border-radius: 8px; + font-family: var(--ifm-font-family-monospace); + font-weight: 600; + font-size: 11px; + color: var(--ifm-color-content); + background: rgba(130, 80, 223, 0.15); + border: 1px solid rgba(130, 80, 223, 0.45); +} + +.transform-gloss { + color: var(--ifm-color-content-secondary); +} + +.variable-value { + font-family: var(--ifm-font-family-monospace); + color: var(--ifm-color-content); + word-break: break-all; +} + .current-opcode .opcode-pc { font-size: 12px; color: var(--ifm-color-content-secondary); @@ -337,6 +497,35 @@ color: var(--ifm-color-content); } +/* Instructions rewritten by the optimizer (transform contexts): a purple + inset accent + tint so the spliced/rewritten region reads as a block in + the list. The inset shadow avoids shifting the row layout, and the + two-class .opcode-item.active rule still wins for the active row. */ +.opcode-item-inline, +.opcode-item-tailcall { + box-shadow: inset 3px 0 0 rgba(130, 80, 223, 0.7); + background: rgba(130, 80, 223, 0.06); +} + +/* Per-instruction transform marker, right-aligned in the row. */ +.opcode-transform-tag { + margin-left: auto; + padding: 0 5px; + border-radius: 8px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + background: rgba(130, 80, 223, 0.15); + color: var(--ifm-color-content); + border: 1px solid rgba(130, 80, 223, 0.45); +} + +/* Inline markers read as "virtual" (dashed), matching the call-stack badge. */ +.opcode-transform-inline { + border-style: dashed; + font-style: italic; +} + /* Instruction object footer - user-resizable, scrolls internally */ .instruction-object-panel { position: relative; diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 79befd851..8558e23d6 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -25,6 +25,15 @@ import { } from "@ethdebug/bugc-react"; import { Executor, createTraceCollector, type TraceStep } from "@ethdebug/evm"; import { dereference, Data, type Machine } from "@ethdebug/pointers"; +import { + buildCallStack, + extractCallInfoFromInstruction, + extractTransformFromInstruction, + type CallFrame, + type CallInfo, + type TraceStep as ProgramsTraceStep, +} from "@ethdebug/programs-react"; +import type { Program } from "@ethdebug/format"; import { Drawer } from "@theme/Drawer"; import { useTracePlayground } from "./TracePlaygroundContext"; @@ -49,6 +58,16 @@ interface CompileResult { bytecode?: BytecodeOutput; } +/** bugc optimizer levels the tracer can compile at. */ +type OptLevel = 0 | 1 | 2 | 3; +const OPT_LEVELS: readonly OptLevel[] = [0, 1, 2, 3]; +const OPT_LEVEL_TITLES: Record = { + 0: "No optimization", + 1: "Level 1 — constant folding, propagation, dead-code elimination", + 2: "Level 2 — adds CSE, tail-call optimization, jump optimization", + 3: "Level 3 — adds block/return/read-write merging", +}; + function TraceDrawerContent(): JSX.Element { const { example, isOpen, toggleDrawer, closeDrawer, setSource } = useTracePlayground(); @@ -131,6 +150,15 @@ function TraceDrawerContent(): JSX.Element { return () => observer.disconnect(); }, [trace.length, clampObjectHeight]); + // Optimizer level the tracer compiles at. Readers flip + // 0 ↔ 2 to watch optimizer transforms (e.g. the tailcall + // annotation on TCO back-edges) appear. A ref mirrors it + // so the example-load effect can read the current value + // without re-running when only the level changes. + const [optimizerLevel, setOptimizerLevel] = useState(0); + const optimizerLevelRef = useRef(optimizerLevel); + optimizerLevelRef.current = optimizerLevel; + // Build PC -> instruction map for source highlighting const pcToInstruction = useMemo(() => { const map = new Map(); @@ -166,18 +194,39 @@ function TraceDrawerContent(): JSX.Element { return extractVariables(instruction.debug.context); }, [trace, currentStep, pcToInstruction]); - // Extract call info from current instruction context - const currentCallInfo = useMemo(() => { - if (trace.length === 0 || currentStep >= trace.length) { - return undefined; + // Adapt the bugc instruction map + evm trace to the shared + // programs-react call-stack helpers, which read the + // ethdebug format shape (instruction.context) and a {pc} + // trace. This lets the drawer reuse the same tailcall-aware + // buildCallStack as the standalone TraceViewer instead of + // duplicating the logic. + const formatPcToInstruction = useMemo(() => { + const m = new Map(); + for (const [pc, inst] of pcToInstruction) { + m.set(pc, { + offset: pc, + context: inst.debug?.context, + } as unknown as Program.Instruction); } + return m; + }, [pcToInstruction]); + const programsTrace = useMemo( + () => trace.map((s) => ({ pc: s.pc, opcode: s.opcode })), + [trace], + ); + + const currentInstruction = useMemo(() => { const step = trace[currentStep]; - const instruction = pcToInstruction.get(step.pc); - if (!instruction?.debug?.context) return undefined; + if (!step) return undefined; + return formatPcToInstruction.get(step.pc); + }, [trace, currentStep, formatPcToInstruction]); - return extractCallInfo(instruction.debug.context); - }, [trace, currentStep, pcToInstruction]); + // Extract call info from current instruction context + const currentCallInfo = useMemo(() => { + if (!currentInstruction) return undefined; + return extractCallInfoFromInstruction(currentInstruction); + }, [currentInstruction]); // Build the ethdebug/format instruction object for the current step const currentFormatInstruction = useMemo(() => { @@ -190,64 +239,30 @@ function TraceDrawerContent(): JSX.Element { return toFormatInstruction(instruction, step.pc); }, [trace, currentStep, pcToInstruction]); - // Build call stack by scanning invoke/return/revert up to - // current step - const callStack = useMemo(() => { - const frames: Array<{ - identifier?: string; - stepIndex: number; - callType?: string; - argumentNames?: string[]; - argumentPointers?: unknown[]; - }> = []; - - for (let i = 0; i <= currentStep && i < trace.length; i++) { - const step = trace[i]; - const instruction = pcToInstruction.get(step.pc); - if (!instruction?.debug?.context) continue; - - const info = extractCallInfo(instruction.debug.context); - if (!info) continue; - - if (info.kind === "invoke") { - // The compiler emits invoke on both the caller - // JUMP and callee entry JUMPDEST for the same - // call. These occur on consecutive trace steps. - // Only skip if the top frame matches AND was - // pushed on the immediately preceding step — - // otherwise this is a new call (e.g. recursion). - const top = frames[frames.length - 1]; - const isDuplicate = - top && - top.identifier === info.identifier && - top.callType === info.callType && - top.stepIndex === i - 1; - if (isDuplicate) { - // Use the callee entry step for resolution — - // argument pointers reference stack slots - // valid at the JUMPDEST, not the JUMP. - // Argument names also live on the callee entry. - top.stepIndex = i; - top.argumentNames = info.argumentNames ?? top.argumentNames; - top.argumentPointers = info.argumentPointers; - } else { - frames.push({ - identifier: info.identifier, - stepIndex: i, - callType: info.callType, - argumentNames: info.argumentNames, - argumentPointers: info.argumentPointers, - }); - } - } else if (info.kind === "return" || info.kind === "revert") { - if (frames.length > 0) { - frames.pop(); - } - } - } + // Compiler transform tags on the current instruction + // (e.g. "tailcall"), for the transform annotations panel. + const currentTransforms = useMemo(() => { + if (!currentInstruction) return []; + return extractTransformFromInstruction(currentInstruction); + }, [currentInstruction]); + + // Build call stack via the shared, tailcall-aware helper. + const callStack = useMemo( + () => buildCallStack(programsTrace, formatPcToInstruction, currentStep), + [programsTrace, formatPcToInstruction, currentStep], + ); - return frames; - }, [trace, currentStep, pcToInstruction]); + // Compiler transform tags per trace step, so the instruction list can + // mark which instructions the optimizer rewrote (e.g. the spliced + // instructions of an inlined body) without stepping onto each one. + const transformsByStep = useMemo( + () => + trace.map((s) => { + const fi = formatPcToInstruction.get(s.pc); + return fi ? extractTransformFromInstruction(fi) : []; + }), + [trace, formatPcToInstruction], + ); // Resolve argument values for call stack frames const argCacheRef = useRef>(new Map()); @@ -322,94 +337,147 @@ function TraceDrawerContent(): JSX.Element { }; }, [callStack, trace, storage]); + // Resolve the current instruction's variable values by + // dereferencing each variable's pointer against the step + // state (reuses the same machinery as argument resolution). + const [resolvedVarValues, setResolvedVarValues] = useState< + Map + >(new Map()); + + useEffect(() => { + const step = trace[currentStep]; + if (!step || currentVariables.length === 0) { + setResolvedVarValues(new Map()); + return; + } + + let cancelled = false; + const state = traceStepToState(step, storage); + const next = new Map(); + + Promise.all( + currentVariables.map(async (v) => { + if (!v.pointer) return; + try { + next.set(v.identifier, await resolvePointer(v.pointer, state)); + } catch { + // leave unresolved + } + }), + ).then(() => { + if (!cancelled) setResolvedVarValues(next); + }); + + return () => { + cancelled = true; + }; + }, [currentVariables, currentStep, trace, storage]); + + // Gas remaining at the current step plus the delta consumed + // reaching it (when the executor reports gas). + const gasText = useMemo(() => { + const step = trace[currentStep]; + if (!step || step.gasRemaining === undefined) return ""; + const rem = step.gasRemaining.toLocaleString(); + const prev = trace[currentStep - 1]; + if (prev?.gasRemaining !== undefined) { + const delta = prev.gasRemaining - step.gasRemaining; + if (delta > 0n) return `gas ${rem} (−${delta.toLocaleString()})`; + } + return `gas ${rem}`; + }, [trace, currentStep]); + // Compile source and run trace in one shot. // Takes source directly to avoid stale-state issues. - const compileAndTrace = useCallback(async (sourceCode: string) => { - setIsCompiling(true); - setCompileResult(null); - setTrace([]); - setCurrentStep(0); - setTraceError(null); - setStorage({}); - - let bytecode: BytecodeOutput | undefined; - - try { - const result = await bugCompile({ - to: "bytecode", - source: sourceCode, - optimizer: { level: 0 }, - }); + const compileAndTrace = useCallback( + async (sourceCode: string, level: OptLevel) => { + setIsCompiling(true); + setCompileResult(null); + setTrace([]); + setCurrentStep(0); + setTraceError(null); + setStorage({}); + + let bytecode: BytecodeOutput | undefined; + + try { + const result = await bugCompile({ + to: "bytecode", + source: sourceCode, + optimizer: { level }, + }); + + if (!result.success) { + const errors = result.messages[Severity.Error] || []; + setCompileResult({ + success: false, + error: errors[0]?.message || "Compilation failed", + }); + return; + } + + bytecode = { + runtime: result.value.bytecode.runtime, + create: result.value.bytecode.create, + runtimeInstructions: result.value.bytecode.runtimeInstructions, + createInstructions: result.value.bytecode.createInstructions, + }; - if (!result.success) { - const errors = result.messages[Severity.Error] || []; + setCompileResult({ success: true, bytecode }); + } catch (e) { setCompileResult({ success: false, - error: errors[0]?.message || "Compilation failed", + error: e instanceof Error ? e.message : String(e), }); return; + } finally { + setIsCompiling(false); } - bytecode = { - runtime: result.value.bytecode.runtime, - create: result.value.bytecode.create, - runtimeInstructions: result.value.bytecode.runtimeInstructions, - createInstructions: result.value.bytecode.createInstructions, - }; - - setCompileResult({ success: true, bytecode }); - } catch (e) { - setCompileResult({ - success: false, - error: e instanceof Error ? e.message : String(e), - }); - return; - } finally { - setIsCompiling(false); - } - - if (!bytecode) return; + if (!bytecode) return; - setIsTracing(true); + setIsTracing(true); - try { - const executor = new Executor(); + try { + const executor = new Executor(); - if (bytecode.create) { - const createHex = Array.from(bytecode.create) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - await executor.deploy(createHex); - } + if (bytecode.create) { + const createHex = Array.from(bytecode.create) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + await executor.deploy(createHex); + } - const [handler, getTrace] = createTraceCollector(); - await executor.execute({}, handler); + const [handler, getTrace] = createTraceCollector(); + await executor.execute({}, handler); - const collectedTrace = getTrace(); - setTrace(collectedTrace.steps); - setCurrentStep(0); + const collectedTrace = getTrace(); + setTrace(collectedTrace.steps); + setCurrentStep(0); - const storageEntries: Record = {}; - for (let i = 0n; i < 16n; i++) { - const value = await executor.getStorage(i); - if (value !== 0n) { - const slot = `0x${i.toString(16).padStart(2, "0")}`; - storageEntries[slot] = `0x${value.toString(16).padStart(64, "0")}`; + const storageEntries: Record = {}; + for (let i = 0n; i < 16n; i++) { + const value = await executor.getStorage(i); + if (value !== 0n) { + const slot = `0x${i.toString(16).padStart(2, "0")}`; + storageEntries[slot] = `0x${value.toString(16).padStart(64, "0")}`; + } } + setStorage(storageEntries); + } catch (e) { + setTraceError(e instanceof Error ? e.message : String(e)); + } finally { + setIsTracing(false); } - setStorage(storageEntries); - } catch (e) { - setTraceError(e instanceof Error ? e.message : String(e)); - } finally { - setIsTracing(false); - } - }, []); + }, + [], + ); // Auto compile+trace when a new example is loaded useEffect(() => { if (example?.source) { setLocalSource(example.source); - compileAndTrace(example.source); + compileAndTrace(example.source, optimizerLevelRef.current); } }, [example, compileAndTrace]); @@ -422,8 +490,17 @@ function TraceDrawerContent(): JSX.Element { ); const handleCompileAndTrace = useCallback(() => { - compileAndTrace(source); - }, [source, compileAndTrace]); + compileAndTrace(source, optimizerLevel); + }, [source, compileAndTrace, optimizerLevel]); + + const handleLevelChange = useCallback( + (level: OptLevel) => { + if (level === optimizerLevel) return; + setOptimizerLevel(level); + compileAndTrace(source, level); + }, + [source, compileAndTrace, optimizerLevel], + ); const stepForward = () => { setCurrentStep((prev) => Math.min(prev + 1, trace.length - 1)); @@ -480,18 +557,42 @@ function TraceDrawerContent(): JSX.Element { const isBusy = isCompiling || isTracing; const headerActions = ( - +
+
+ Opt + {OPT_LEVELS.map((level) => ( + + ))} +
+ +
); return ( @@ -607,6 +708,22 @@ function TraceDrawerContent(): JSX.Element { > {frame.identifier || "(anonymous)"}( {formatFrameArgs(frame, resolvedArgs)}) + {frame.isTailCall && ( + + ⮌ tail call + + )} + {frame.isInline && ( + + ⧉ inline + + )} )) @@ -615,7 +732,13 @@ function TraceDrawerContent(): JSX.Element { {currentCallInfo && (
{formatCallBanner(currentCallInfo)}
@@ -628,6 +751,7 @@ function TraceDrawerContent(): JSX.Element { trace={trace} currentStep={currentStep} onStepClick={setCurrentStep} + transformsByStep={transformsByStep} />
@@ -639,23 +763,34 @@ function TraceDrawerContent(): JSX.Element { @ 0x{currentTraceStep.pc.toString(16)} + {gasText && ( + {gasText} + )}
-
Stack
- + {currentTransforms.length > 0 && ( +
+ +
+ )} + +
+ +
{currentVariables.length > 0 && ( - <> -
Variables
- - +
+ +
)} {Object.keys(storage).length > 0 && ( - <> -
Storage
+
- +
)} )} @@ -728,12 +863,15 @@ interface OpcodeListProps { trace: TraceStep[]; currentStep: number; onStepClick: (index: number) => void; + /** Compiler transform tags per trace step (same index as `trace`). */ + transformsByStep: string[][]; } function OpcodeList({ trace, currentStep, onStepClick, + transformsByStep, }: OpcodeListProps): JSX.Element { // Render the full instruction list; the panel scrolls internally. // Keep the active step scrolled into view as the trace advances. @@ -747,11 +885,22 @@ function OpcodeList({
{trace.map((step, index) => { const isActive = index === currentStep; + const transforms = transformsByStep[index] ?? []; + const isInline = transforms.includes("inline"); + const isTailCall = transforms.includes("tailcall"); + const className = [ + "opcode-item", + isActive ? "active" : "", + isInline ? "opcode-item-inline" : "", + isTailCall ? "opcode-item-tailcall" : "", + ] + .filter(Boolean) + .join(" "); return (
onStepClick(index)} > {index + 1} @@ -759,6 +908,24 @@ function OpcodeList({ 0x{step.pc.toString(16).padStart(4, "0")} {step.opcode} + {isInline && ( + + ⧉ inline + + )} + {isTailCall && ( + + ⮌ tailcall + + )}
); })} @@ -858,107 +1025,104 @@ function formatBigInt(value: bigint): string { interface Variable { identifier: string; type?: string; + pointer?: unknown; } interface VariablesDisplayProps { variables: Variable[]; + resolved: Map; } -function VariablesDisplay({ variables }: VariablesDisplayProps): JSX.Element { +function VariablesDisplay({ + variables, + resolved, +}: VariablesDisplayProps): JSX.Element { return (
- {variables.map((variable, i) => ( -
- {variable.identifier} - {variable.type && ( - {variable.type} - )} + {variables.map((variable, i) => { + const value = resolved.get(variable.identifier); + return ( +
+ {variable.identifier} + {value !== undefined && ( + {formatAsDecimal(value)} + )} + {variable.type && ( + {variable.type} + )} +
+ ); + })} +
+ ); +} + +/** One-line glosses for known transform identifiers. */ +const TRANSFORM_GLOSS: Record = { + tailcall: "tail call — frame reused, no new activation (TCO)", + inline: "inlined function body", + fold: "constant-folded at compile time", + coalesce: "merged read/write sequence", +}; + +function TransformList({ transforms }: { transforms: string[] }): JSX.Element { + return ( +
+ {transforms.map((t, i) => ( +
+ {t} + + {TRANSFORM_GLOSS[t] ?? "compiler transform"} +
))}
); } -/** - * Info about a call context (invoke/return/revert). - */ -interface CallInfoResult { - kind: "invoke" | "return" | "revert"; - identifier?: string; - callType?: string; - argumentNames?: string[]; - argumentPointers?: unknown[]; +/** A collapsible right-column section. */ +function Section({ + title, + defaultOpen = true, + children, +}: { + title: string; + defaultOpen?: boolean; + children: React.ReactNode; +}): JSX.Element { + return ( +
+ {title} + {children} +
+ ); } /** - * Extract call info from an ethdebug format context object. + * Info about a call context (invoke/return/revert). */ -function extractCallInfo(context: unknown): CallInfoResult | undefined { - if (!context || typeof context !== "object") { - return undefined; - } - - const ctx = context as Record; - - if ("invoke" in ctx && ctx.invoke) { - const inv = ctx.invoke as Record; - let callType: string | undefined; - if ("jump" in inv) callType = "internal"; - else if ("message" in inv) callType = "external"; - else if ("create" in inv) callType = "create"; - - const argInfo = extractArgInfoFromInvoke(inv); - return { - kind: "invoke", - identifier: inv.identifier as string | undefined, - callType, - argumentNames: argInfo?.names, - argumentPointers: argInfo?.pointers, - }; - } - - if ("return" in ctx && ctx.return) { - const ret = ctx.return as Record; - return { - kind: "return", - identifier: ret.identifier as string | undefined, - }; - } - - if ("revert" in ctx && ctx.revert) { - const rev = ctx.revert as Record; - return { - kind: "revert", - identifier: rev.identifier as string | undefined, - }; - } - - // Walk gather/pick - if ("gather" in ctx && Array.isArray(ctx.gather)) { - for (const sub of ctx.gather) { - const info = extractCallInfo(sub); - if (info) return info; - } - } - - if ("pick" in ctx && Array.isArray(ctx.pick)) { - for (const sub of ctx.pick) { - const info = extractCallInfo(sub); - if (info) return info; - } - } - - return undefined; -} - /** * Format a call info banner string. */ -function formatCallBanner(info: CallInfoResult): string { +function formatCallBanner(info: CallInfo): string { const name = info.identifier || "(anonymous)"; const params = info.argumentNames ? `(${info.argumentNames.join(", ")})` : "()"; + if (info.isTailCall) { + return `Tail call: ${name} (frame reused)`; + } + if (info.isInline) { + // No call actually occurs — the body was spliced in at compile time. + switch (info.kind) { + case "invoke": + return `Inlined ${name}${params} (no call — body spliced in)`; + case "return": + return `End of inlined ${name}()`; + case "revert": + return `Reverted in inlined ${name}()`; + } + } switch (info.kind) { case "invoke": { const prefix = info.callType === "create" ? "Creating" : "Calling"; @@ -971,38 +1135,6 @@ function formatCallBanner(info: CallInfoResult): string { } } -function extractArgInfoFromInvoke( - inv: Record, -): { names?: string[]; pointers?: unknown[] } | undefined { - const args = inv.arguments as Record | undefined; - if (!args) return undefined; - - const pointer = args.pointer as Record | undefined; - if (!pointer) return undefined; - - const group = pointer.group as Array> | undefined; - if (!Array.isArray(group)) return undefined; - - const names: string[] = []; - const pointers: unknown[] = []; - let hasAnyName = false; - for (const entry of group) { - const name = entry.name as string | undefined; - if (name) { - names.push(name); - hasAnyName = true; - } else { - names.push("_"); - } - pointers.push(entry); - } - - return { - names: hasAnyName ? names : undefined, - pointers, - }; -} - /** * Extract variables from an ethdebug format context object. */ @@ -1022,6 +1154,7 @@ function extractVariables(context: unknown): Variable[] { variables.push({ identifier: String(variable.identifier), type: variable.type ? formatType(variable.type) : undefined, + pointer: variable.pointer, }); } } diff --git a/schemas/program/context.schema.yaml b/schemas/program/context.schema.yaml index a57fce654..1a82e76df 100644 --- a/schemas/program/context.schema.yaml +++ b/schemas/program/context.schema.yaml @@ -89,6 +89,14 @@ allOf: description: | Indicates association with a function revert. $ref: "schema:ethdebug/format/program/context/function/revert" + - if: + required: ["transform"] + then: + description: | + Compiler transformations applied to produce this instruction + (e.g., inlining, tail-call optimization). Additional + annotation — does not replace semantic contexts. + $ref: "schema:ethdebug/format/program/context/transform" unevaluatedProperties: false diff --git a/schemas/program/context/function/invoke.schema.yaml b/schemas/program/context/function/invoke.schema.yaml index c38c9cb9b..11c8d9edf 100644 --- a/schemas/program/context/function/invoke.schema.yaml +++ b/schemas/program/context/function/invoke.schema.yaml @@ -325,6 +325,30 @@ examples: length: 95 jump: true + # ----------------------------------------------------------- + # Inlined internal call: no target pointer + # ----------------------------------------------------------- + # When the compiler inlines a function, the JUMP that would + # normally carry the invoke context has been elided — there + # is no physical call instruction and no code target to + # point at. The invoke context still records the callee's + # identity so the debugger can maintain a source-level call + # stack, and a `transform: ["inline"]` context (typically + # via `gather`) annotates the inlining. + - invoke: + identifier: "transfer" + declaration: + source: + id: 0 + range: + offset: 128 + length: 95 + jump: true + # Correlation id: the matching inlined `return` carries the + # same value, so the two pair even if `transfer` is inlined + # at several sites. + activation: "transfer#0" + # ----------------------------------------------------------- # External CALL: token.balanceOf(account) # ----------------------------------------------------------- diff --git a/schemas/program/context/transform.schema.yaml b/schemas/program/context/transform.schema.yaml new file mode 100644 index 000000000..1041dc848 --- /dev/null +++ b/schemas/program/context/transform.schema.yaml @@ -0,0 +1,79 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "schema:ethdebug/format/program/context/transform" + +title: ethdebug/format/program/context/transform +description: | + Annotates an instruction with compiler transformations that + produced it. The value is a list of short identifiers naming + each transformation; the list may repeat an identifier when + the same transformation has been applied more than once (e.g., + `["inline", "inline"]` for doubly-inlined code). + + A transform context is *additional* annotation — it does not + replace semantic contexts. When the compiler inlines a + function, the invoke/return contexts for the logical call + should still be emitted at the call boundary so the debugger's + source-level call stack remains coherent. The transform + context tells debuggers **how** the call was realized. + + Combine a transform with other discriminator keys (`invoke`, + `return`, `code`, etc.) by placing them side-by-side on the + same context object — `gather` is only needed when two + contexts would collide on the same key. + + Consumers that ignore transform contexts still get a sound + source-level view from the invoke/return contexts alone. + Consumers that understand transform contexts can offer + optimization-aware presentations — e.g., rendering inlined + code as a collapsible block, or reconciling tail-call-optimized + back-edges with the logical call stack. + + The identifier set is extensible. The schema defines: + + - `"inline"` — the marked instruction is part of an inlined + function body. Surrounding invoke/return contexts name the + inlined callee. + - `"tailcall"` — the marked instruction is a + tail-call-optimized back-edge JUMP or continuation, where + the call was realized as a direct jump (or reuse of the + caller's frame) rather than a standard call/return sequence. + - `"fold"` — the marked instruction carries the result of a + compile-time constant fold. Typically a PUSH of the folded + value, replacing a compute sequence that appeared in source. + - `"coalesce"` — the marked instruction is part of a + read-write merging sequence (e.g., SHL/OR sequences packing + narrower fields into a wider word) that the user did not + explicitly write; the compiler introduced it to combine + adjacent source-level reads or writes. + + Debuggers unfamiliar with a given identifier should preserve + it as an opaque label. + + Order in the array is not semantically significant — only the + multiset of identifiers matters. + +type: object +properties: + transform: + title: Applied transformations + description: | + List of transformation identifiers. Identifiers may + repeat; order is not semantically significant. + type: array + items: + type: string + minLength: 1 + minItems: 1 + +required: + - transform + +examples: + - transform: ["inline"] + - transform: ["tailcall"] + - transform: ["fold"] + - transform: ["coalesce"] + - transform: ["inline", "inline"] + - transform: ["inline", "tailcall"] + - transform: ["inline", "fold"] + - transform: ["coalesce", "coalesce"]