From 73d57e407809c355956bb071860c85267ff71d10 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 15 Jul 2026 22:28:38 -0400 Subject: [PATCH 1/7] format: add the transform context for compiler optimizations Introduce the `transform` context: a flat sibling key on an instruction listing the optimization passes (fold, coalesce, tailcall, inline) the compiler applied to it, so a debugger can distinguish compiler-synthesized code from the source the user wrote. Adds the schema, the format TypeScript type, and the spec page, and registers the context page in the docs schema index. --- .../format/src/types/program/context.test.ts | 4 + packages/format/src/types/program/context.ts | 30 ++++- .../spec/program/context/function/invoke.mdx | 4 +- packages/web/spec/program/context/gather.mdx | 5 +- .../web/spec/program/context/transform.mdx | 119 ++++++++++++++++++ packages/web/src/schemas.ts | 11 +- schemas/program/context.schema.yaml | 8 ++ .../context/function/invoke.schema.yaml | 20 +++ schemas/program/context/transform.schema.yaml | 79 ++++++++++++ 9 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 packages/web/spec/program/context/transform.mdx create mode 100644 schemas/program/context/transform.schema.yaml diff --git a/packages/format/src/types/program/context.test.ts b/packages/format/src/types/program/context.test.ts index 88bbf537c6..55a7161815 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 14ab33c4ae..d6834bdb76 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/web/spec/program/context/function/invoke.mdx b/packages/web/spec/program/context/function/invoke.mdx index 617a2b5d85..af1c1da7a2 100644 --- a/packages/web/spec/program/context/function/invoke.mdx +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -56,7 +56,9 @@ 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. + +## 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. Surrounding invoke/return contexts name the + inlined callee; this marker tells the debugger the physical + code does not correspond to a separate 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. + +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`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 84ba8c7012..31496043aa 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/schemas/program/context.schema.yaml b/schemas/program/context.schema.yaml index a57fce654a..1a82e76dff 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 c38c9cb9b1..6387a5d4f6 100644 --- a/schemas/program/context/function/invoke.schema.yaml +++ b/schemas/program/context/function/invoke.schema.yaml @@ -325,6 +325,26 @@ 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 + # ----------------------------------------------------------- # 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 0000000000..1041dc8482 --- /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"] From 962c5aa5f7774ae1f1148deb0956a6950f1e6c07 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 15 Jul 2026 22:29:51 -0400 Subject: [PATCH 2/7] tailcall: emit, render, and document the tail-call transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit `transform: ["tailcall"]` on the TCO back-edge JUMP, alongside the invoke/return pair it already carries, so a debugger sees the pair as a tail-call-optimized back-edge rather than a real frame push/pop and does not invent a spurious frame. Render it in the tracer drawer — an optimizer-level (O0-O3) selector and a tail-call-aware call stack — and document it with a tracing example and a walkthrough homed in the trace playground. Introduces the shared `Ir.Utils.addTransform` helper — the fold, coalesce, and inline emitters all route through it — and the transform-emission test file (`transform-contexts.test.ts`, with the `countTransform` helper), covering the tailcall marker: absent at O0/O1, present at O2/O3. --- .../generation/control-flow/terminator.ts | 26 +- .../src/evmgen/optimizer-contexts.test.ts | 1 + .../src/evmgen/transform-contexts.test.ts | 101 +++ packages/bugc/src/ir/utils/debug.ts | 33 + .../src/components/CallInfoPanel.css | 6 + .../src/components/CallInfoPanel.tsx | 17 +- .../src/components/CallStackDisplay.css | 12 + .../src/components/CallStackDisplay.tsx | 8 + .../src/components/TraceContext.tsx | 6 + packages/programs-react/src/index.ts | 1 + packages/programs-react/src/utils/index.ts | 1 + .../src/utils/mockTrace.test.ts | 173 ++++- .../programs-react/src/utils/mockTrace.ts | 67 +- .../core-schemas/programs/tracing-examples.ts | 42 ++ .../docs/core-schemas/programs/tracing.mdx | 43 ++ packages/web/docs/explore/index.mdx | 3 +- .../web/docs/explore/trace-playground.mdx | 32 +- .../web/spec/program/context/transform.mdx | 3 +- .../theme/ProgramExample/CallInfoPanel.css | 6 + .../theme/ProgramExample/CallStackDisplay.css | 12 + .../src/theme/ProgramExample/TraceDrawer.css | 144 +++++ .../src/theme/ProgramExample/TraceDrawer.tsx | 595 ++++++++++-------- 22 files changed, 1050 insertions(+), 282 deletions(-) create mode 100644 packages/bugc/src/evmgen/transform-contexts.test.ts diff --git a/packages/bugc/src/evmgen/generation/control-flow/terminator.ts b/packages/bugc/src/evmgen/generation/control-flow/terminator.ts index 0117bf24e8..933f104f84 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/optimizer-contexts.test.ts b/packages/bugc/src/evmgen/optimizer-contexts.test.ts index 51805d7ffc..9032de34e0 100644 --- a/packages/bugc/src/evmgen/optimizer-contexts.test.ts +++ b/packages/bugc/src/evmgen/optimizer-contexts.test.ts @@ -524,6 +524,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 0000000000..f21159e002 --- /dev/null +++ b/packages/bugc/src/evmgen/transform-contexts.test.ts @@ -0,0 +1,101 @@ +/** + * 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); + }); + } +}); diff --git a/packages/bugc/src/ir/utils/debug.ts b/packages/bugc/src/ir/utils/debug.ts index 5feb48b9c9..13516fe713 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/programs-react/src/components/CallInfoPanel.css b/packages/programs-react/src/components/CallInfoPanel.css index 75cd065118..b2835861c7 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 09da2b3d7e..e0e10ed2be 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 9143b8d764..90afee044d 100644 --- a/packages/programs-react/src/components/CallStackDisplay.css +++ b/packages/programs-react/src/components/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/programs-react/src/components/CallStackDisplay.tsx b/packages/programs-react/src/components/CallStackDisplay.tsx index 09e2bf7aa3..983f75be12 100644 --- a/packages/programs-react/src/components/CallStackDisplay.tsx +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -94,6 +94,14 @@ export function CallStackDisplay({ ({formatArgs(frame, resolvedCallStack)}) + {frame.isTailCall && ( + + ⮌ tail call + + )} ))} diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 581b721438..795f3f2f44 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 6253933c2f..8d86107711 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 a79f07b081..e6dfdbefb7 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 531cfab43a..61462608da 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,8 @@ import { describe, it, expect } from "vitest"; import type { Program } from "@ethdebug/format"; import { + extractTransformFromInstruction, + extractCallInfoFromInstruction, buildCallStack, buildPcToInstructionMap, type TraceStep, @@ -21,6 +24,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) @@ -87,3 +184,77 @@ describe("buildCallStack flat return+invoke back-edge", () => { expect(buildCallStack(normalTrace, map, 1)).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); + }); +}); diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index 7710b724d4..74d08895c1 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,6 +119,49 @@ 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; +} + +/** + * 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 []; } /** @@ -131,7 +174,14 @@ export function extractCallInfoFromInstruction( if (!instruction.context) { return undefined; } - return extractCallInfoFromContext(instruction.context); + const info = extractCallInfoFromContext(instruction.context); + if (!info) { + return undefined; + } + const isTailCall = extractTransformFromContext(instruction.context).includes( + "tailcall", + ); + return isTailCall ? { ...info, isTailCall: true } : info; } function extractCallInfoFromContext( @@ -274,6 +324,11 @@ 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; } /** @@ -304,7 +359,12 @@ export function buildCallStack( // 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. Frame reuse is detected + // structurally (return + invoke together), independent of any + // transform marker, so the call stack stays correct even for + // consumers that ignore transforms. The isTailCall *label* + // (which drives the call-stack chip / info banner) follows the + // `tailcall` transform marker, surfaced via callInfo.isTailCall. const ctx = instruction.context as Record | undefined; const backEdgeInvoke = ctx ? findInvokeField(ctx) : undefined; if (ctx && backEdgeInvoke && hasReturnContext(ctx)) { @@ -317,6 +377,7 @@ export function buildCallStack( callType: invokeCallType(backEdgeInvoke), argumentNames: argResult?.names, argumentPointers: argResult?.pointers, + isTailCall: callInfo.isTailCall, }; if (stack.length > 0) { stack[stack.length - 1] = frame; diff --git a/packages/web/docs/core-schemas/programs/tracing-examples.ts b/packages/web/docs/core-schemas/programs/tracing-examples.ts index 08cb5f8df4..a6c67fc5d2 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -96,3 +96,45 @@ 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); +}`; diff --git a/packages/web/docs/core-schemas/programs/tracing.mdx b/packages/web/docs/core-schemas/programs/tracing.mdx index 6c680752f2..67e2e234a7 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 867c4977dd..4d1ffd515d 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 fda4c7e246..ff4b41c20c 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, + tailRecursiveSum, + tailRecursiveFactorial, } 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,30 @@ 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 +these tail-recursive programs: the recursive call folds into a loop, and +the back-edge JUMP carries `transform: ["tailcall"]` next to its invoke +and return. The call stack stays flat instead of growing one frame per +iteration. + + + + + +Flip the **Opt** selector between **O0** and **O2** to see the call stack +change. For how the `tailcall` transform composes 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/transform.mdx b/packages/web/spec/program/context/transform.mdx index accb37769f..7b79311c9f 100644 --- a/packages/web/spec/program/context/transform.mdx +++ b/packages/web/spec/program/context/transform.mdx @@ -114,6 +114,7 @@ single JUMP. 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`s from different +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/theme/ProgramExample/CallInfoPanel.css b/packages/web/src/theme/ProgramExample/CallInfoPanel.css index 75cd065118..b2835861c7 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 9143b8d764..90afee044d 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 95cc0f7691..20912e4f1c 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,26 @@ 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; +} + +.call-stack-tailcall { + 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); +} + /* Trace panels - this row grows to fill the drawer's height */ .trace-panels { display: grid; @@ -225,6 +300,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 +366,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); diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 79befd851a..6ec852685d 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,18 @@ 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(); - } - } - } - - return frames; - }, [trace, currentStep, pcToInstruction]); + // 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], + ); // Resolve argument values for call stack frames const argCacheRef = useRef>(new Map()); @@ -322,94 +325,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 +478,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 +545,42 @@ function TraceDrawerContent(): JSX.Element { const isBusy = isCompiling || isTracing; const headerActions = ( - +
+
+ Opt + {OPT_LEVELS.map((level) => ( + + ))} +
+ +
); return ( @@ -607,6 +696,14 @@ function TraceDrawerContent(): JSX.Element { > {frame.identifier || "(anonymous)"}( {formatFrameArgs(frame, resolvedArgs)}) + {frame.isTailCall && ( + + ⮌ tail call + + )} )) @@ -615,7 +712,11 @@ function TraceDrawerContent(): JSX.Element { {currentCallInfo && (
{formatCallBanner(currentCallInfo)}
@@ -639,23 +740,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
+
- +
)} )} @@ -858,107 +970,93 @@ 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)`; + } switch (info.kind) { case "invoke": { const prefix = info.callType === "create" ? "Creating" : "Calling"; @@ -971,38 +1069,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 +1088,7 @@ function extractVariables(context: unknown): Variable[] { variables.push({ identifier: String(variable.identifier), type: variable.type ? formatType(variable.type) : undefined, + pointer: variable.pointer, }); } } From 4fc242c30f64b97dae67318799093a844b0d6af4 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 15 Jul 2026 22:29:51 -0400 Subject: [PATCH 3/7] bugc: emit the fold transform on constant folding Mark constant-folded values with `transform: ["fold"]` through a shared `addTransform` helper, so the tracer can show a value as a compile-time-evaluated constant rather than source. The marker composes with later passes into a multiset (e.g. `["fold", "coalesce"]`). --- .../src/evmgen/transform-contexts.test.ts | 21 +++++++++++++++++++ .../src/optimizer/steps/constant-folding.ts | 10 +++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/bugc/src/evmgen/transform-contexts.test.ts b/packages/bugc/src/evmgen/transform-contexts.test.ts index f21159e002..3e07ff36fd 100644 --- a/packages/bugc/src/evmgen/transform-contexts.test.ts +++ b/packages/bugc/src/evmgen/transform-contexts.test.ts @@ -99,3 +99,24 @@ code { r = sum(5, 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); + }); + } +}); diff --git a/packages/bugc/src/optimizer/steps/constant-folding.ts b/packages/bugc/src/optimizer/steps/constant-folding.ts index 20f99d585a..725c7f5306 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", + ), }; } From 5640bfd31aa88c8b13d64d23ced4a5848c0ca81e Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 15 Jul 2026 22:29:51 -0400 Subject: [PATCH 4/7] bugc: emit the coalesce transform on read-write merging Mark the SHL/OR field-packing sequence produced by read-write merging with `transform: ["coalesce"]`, so a packed-storage write reads as compiler-synthesized rather than source the user wrote. Composes with a folded value into `["fold", "coalesce"]`. --- .../src/evmgen/transform-contexts.test.ts | 26 +++++++++++++++++++ .../src/optimizer/steps/read-write-merging.ts | 12 +++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/bugc/src/evmgen/transform-contexts.test.ts b/packages/bugc/src/evmgen/transform-contexts.test.ts index 3e07ff36fd..677b48500e 100644 --- a/packages/bugc/src/evmgen/transform-contexts.test.ts +++ b/packages/bugc/src/evmgen/transform-contexts.test.ts @@ -120,3 +120,29 @@ code { r = (2 + 3) * (4 * 5); }`; }); } }); + +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, + ); + }); +}); diff --git a/packages/bugc/src/optimizer/steps/read-write-merging.ts b/packages/bugc/src/optimizer/steps/read-write-merging.ts index 1ac2ecde5f..cb24ba1c7b 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; } From 373b030273690234605ada78d4d2add0772e03d8 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 15 Jul 2026 22:30:47 -0400 Subject: [PATCH 5/7] inline: spec, compiler pass, tracer reconstruction, and examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the function-inlining transform end to end: - spec the inlined-call virtual-activation contract — push/pop pairing via the virtual invoke/return versus the per-instruction `inline` membership marker; - add the bugc inlining pass (level 2): splice eligible leaf callees into their call sites, emit `transform: ["inline"]` on the spliced body, and bracket it with a virtual invoke/return; bracket those boundary ops in evmgen so the activation nests correctly; - reconstruct the inline virtual activations in the tracer's call stack; - add the inlining showcase examples and the trace-playground section. --- packages/bugc/src/evmgen/generation/block.ts | 27 +- .../evmgen/generation/bracket-activation.ts | 164 +++++++ .../bugc/src/evmgen/inline-bracket.test.ts | 192 ++++++++ .../src/evmgen/optimizer-contexts.test.ts | 15 +- .../src/evmgen/transform-contexts.test.ts | 33 ++ .../bugc/src/optimizer/simple-optimizer.ts | 3 +- .../bugc/src/optimizer/steps/inlining.test.ts | 67 ++- packages/bugc/src/optimizer/steps/inlining.ts | 109 ++++- .../src/components/CallStackDisplay.css | 10 +- .../src/components/CallStackDisplay.tsx | 8 + .../src/utils/mockTrace.test.ts | 455 +++++++++++++++++- .../programs-react/src/utils/mockTrace.ts | 332 ++++++++----- .../core-schemas/programs/tracing-examples.ts | 24 + .../web/docs/explore/trace-playground.mdx | 42 +- .../spec/program/context/function/invoke.mdx | 124 +++++ .../web/spec/program/context/transform.mdx | 27 +- .../src/theme/ProgramExample/TraceDrawer.css | 10 +- .../src/theme/ProgramExample/TraceDrawer.tsx | 8 + 18 files changed, 1470 insertions(+), 180 deletions(-) create mode 100644 packages/bugc/src/evmgen/generation/bracket-activation.ts create mode 100644 packages/bugc/src/evmgen/inline-bracket.test.ts diff --git a/packages/bugc/src/evmgen/generation/block.ts b/packages/bugc/src/evmgen/generation/block.ts index fffc065d28..ab203771c9 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 0000000000..653c2ffc87 --- /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/inline-bracket.test.ts b/packages/bugc/src/evmgen/inline-bracket.test.ts new file mode 100644 index 0000000000..6583bcfed8 --- /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 9032de34e0..fbf11e5ac2 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({ diff --git a/packages/bugc/src/evmgen/transform-contexts.test.ts b/packages/bugc/src/evmgen/transform-contexts.test.ts index 677b48500e..28e93bfff1 100644 --- a/packages/bugc/src/evmgen/transform-contexts.test.ts +++ b/packages/bugc/src/evmgen/transform-contexts.test.ts @@ -146,3 +146,36 @@ code { let v = src; s.a = v; s.b = v; }`; ); }); }); + +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/optimizer/simple-optimizer.ts b/packages/bugc/src/optimizer/simple-optimizer.ts index 1b52a0cdb6..7c64642d70 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/inlining.test.ts b/packages/bugc/src/optimizer/steps/inlining.test.ts index 6680747843..36a4d5919e 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 e2bd4b712d..3cac38370e 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/programs-react/src/components/CallStackDisplay.css b/packages/programs-react/src/components/CallStackDisplay.css index 90afee044d..33790e6a23 100644 --- a/packages/programs-react/src/components/CallStackDisplay.css +++ b/packages/programs-react/src/components/CallStackDisplay.css @@ -49,7 +49,8 @@ color: var(--programs-text-muted, #888); } -.call-stack-tailcall { +.call-stack-tailcall, +.call-stack-inline { margin-left: 4px; padding: 0 5px; border-radius: 8px; @@ -60,3 +61,10 @@ 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 983f75be12..f8dfc4b052 100644 --- a/packages/programs-react/src/components/CallStackDisplay.tsx +++ b/packages/programs-react/src/components/CallStackDisplay.tsx @@ -102,6 +102,14 @@ export function CallStackDisplay({ ⮌ tail call )} + {frame.isInline && ( + + ⧉ inline + + )} ))} diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts index 61462608da..21ae3e431f 100644 --- a/packages/programs-react/src/utils/mockTrace.test.ts +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -10,6 +10,7 @@ import type { Program } from "@ethdebug/format"; import { extractTransformFromInstruction, extractCallInfoFromInstruction, + extractCallEvents, buildCallStack, buildPcToInstructionMap, type TraceStep, @@ -168,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" } }), @@ -177,11 +182,16 @@ 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); }); }); @@ -258,3 +268,440 @@ describe("flat (production) TCO back-edge shape", () => { 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 74d08895c1..e4acbd5059 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -125,6 +125,12 @@ export interface CallInfo { * (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; } /** @@ -165,112 +171,136 @@ function extractTransformFromContext(context: Program.Context): string[] { } /** - * Extract call info (invoke/return/revert) from an - * instruction's context tree. + * 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 []; } - const info = extractCallInfoFromContext(instruction.context); - if (!info) { - return undefined; + const transforms = extractTransformFromContext(instruction.context); + const isTailCall = transforms.includes("tailcall"); + const isInline = transforms.includes("inline"); + if (!isTailCall && !isInline) { + return events; } - const isTailCall = extractTransformFromContext(instruction.context).includes( - "tailcall", - ); - return isTailCall ? { ...info, isTailCall: true } : info; + 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( @@ -329,6 +359,13 @@ export interface CallFrame { * (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; } /** @@ -349,35 +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. Frame reuse is detected - // structurally (return + invoke together), independent of any - // transform marker, so the call stack stays correct even for - // consumers that ignore transforms. The isTailCall *label* - // (which drives the call-stack chip / info banner) follows the - // `tailcall` transform marker, surfaced via callInfo.isTailCall. + // 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: callInfo.isTailCall, + isTailCall: transforms.includes("tailcall"), }; if (stack.length > 0) { stack[stack.length - 1] = frame; @@ -387,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 a6c67fc5d2..24fecdfecf 100644 --- a/packages/web/docs/core-schemas/programs/tracing-examples.ts +++ b/packages/web/docs/core-schemas/programs/tracing-examples.ts @@ -138,3 +138,27 @@ create { 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/explore/trace-playground.mdx b/packages/web/docs/explore/trace-playground.mdx index ff4b41c20c..b74db5337b 100644 --- a/packages/web/docs/explore/trace-playground.mdx +++ b/packages/web/docs/explore/trace-playground.mdx @@ -8,8 +8,8 @@ import { TracePlayground, TraceExample } from "@theme/ProgramExample"; import { counterIncrement, functionCallAndReturn, - tailRecursiveSum, tailRecursiveFactorial, + inlineDemo, } from "../core-schemas/programs/tracing-examples"; # Trace playground @@ -57,16 +57,14 @@ For the exact shape of invoke, return, and revert contexts, see the Compilers rewrite code as they optimize, and **transform** contexts record what they did. Set the **Opt** selector to **O2** and step through -these tail-recursive programs: the recursive call folds into a loop, and -the back-edge JUMP carries `transform: ["tailcall"]` next to its invoke -and return. The call stack stays flat instead of growing one frame per -iteration. +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. -Flip the **Opt** selector between **O0** and **O2** to see the call stack -change. For how the `tailcall` transform composes with invoke/return, see -the [transform context spec](/spec/program/context/transform) and the +### 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 af1c1da7a2..554d3414d9 100644 --- a/packages/web/spec/program/context/function/invoke.mdx +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -65,6 +65,35 @@ than physically invoked. pointer="#/$defs/InternalCall" /> +### 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, @@ -86,3 +115,98 @@ 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. + +### 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/transform.mdx b/packages/web/spec/program/context/transform.mdx index 7b79311c9f..b9070907f6 100644 --- a/packages/web/spec/program/context/transform.mdx +++ b/packages/web/spec/program/context/transform.mdx @@ -42,9 +42,12 @@ optimization-aware presentations: The schema defines four identifiers: - **`"inline"`** — the marked instruction is part of an inlined - function body. Surrounding invoke/return contexts name the - inlined callee; this marker tells the debugger the physical - code does not correspond to a separate activation record. + 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. @@ -112,6 +115,24 @@ The `return` and `invoke` state the source-level facts `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 diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.css b/packages/web/src/theme/ProgramExample/TraceDrawer.css index 20912e4f1c..ea9f1a6dec 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.css +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.css @@ -270,7 +270,8 @@ border-left: 3px solid #8250df; } -.call-stack-tailcall { +.call-stack-tailcall, +.call-stack-inline { margin-left: 4px; padding: 0 5px; border-radius: 8px; @@ -282,6 +283,13 @@ 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; diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 6ec852685d..a2189d5f77 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -704,6 +704,14 @@ function TraceDrawerContent(): JSX.Element { ⮌ tail call )} + {frame.isInline && ( + + ⧉ inline + + )} )) From 1f24e8058c3e2512f826b5380cedd113c635cf7f Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 15 Jul 2026 22:35:44 -0400 Subject: [PATCH 6/7] format: correlate activations with an activation identifier Correlate activations with an `activation` identifier on invoke, return, and revert: the invoke that opens an activation and the return or revert that closes it carry the same value, so a debugger pairs a call with its return independent of trace order. It lives inside the invoke/return/revert object, keeping a tail call's two facts flat without a `gather`. --- .../spec/program/context/function/invoke.mdx | 35 +++++++++++++++++++ .../context/function/invoke.schema.yaml | 4 +++ 2 files changed, 39 insertions(+) diff --git a/packages/web/spec/program/context/function/invoke.mdx b/packages/web/spec/program/context/function/invoke.mdx index 554d3414d9..b04f44b2de 100644 --- a/packages/web/spec/program/context/function/invoke.mdx +++ b/packages/web/spec/program/context/function/invoke.mdx @@ -188,6 +188,41 @@ 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`, diff --git a/schemas/program/context/function/invoke.schema.yaml b/schemas/program/context/function/invoke.schema.yaml index 6387a5d4f6..11c8d9edf7 100644 --- a/schemas/program/context/function/invoke.schema.yaml +++ b/schemas/program/context/function/invoke.schema.yaml @@ -344,6 +344,10 @@ examples: 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) From e08afea37650ace0dbddd4be8175471abd1da0b3 Mon Sep 17 00:00:00 2001 From: "g. nicholas d'andrea" Date: Wed, 15 Jul 2026 22:35:44 -0400 Subject: [PATCH 7/7] web: surface optimizer transforms in the tracer instruction list Show the tailcall and inline transform annotations inline in the tracer's instruction list, so the optimizer passes applied to each instruction are visible as you step through the trace. --- .../src/theme/ProgramExample/TraceDrawer.css | 37 +++++++++++ .../src/theme/ProgramExample/TraceDrawer.tsx | 66 +++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.css b/packages/web/src/theme/ProgramExample/TraceDrawer.css index ea9f1a6dec..71b749b671 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.css +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.css @@ -270,6 +270,14 @@ 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; @@ -489,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 a2189d5f77..8558e23d6b 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -252,6 +252,18 @@ function TraceDrawerContent(): JSX.Element { [programsTrace, formatPcToInstruction, currentStep], ); + // 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()); @@ -721,9 +733,11 @@ function TraceDrawerContent(): JSX.Element { {currentCallInfo && (
{formatCallBanner(currentCallInfo)} @@ -737,6 +751,7 @@ function TraceDrawerContent(): JSX.Element { trace={trace} currentStep={currentStep} onStepClick={setCurrentStep} + transformsByStep={transformsByStep} />
@@ -848,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. @@ -867,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} @@ -879,6 +908,24 @@ function OpcodeList({ 0x{step.pc.toString(16).padStart(4, "0")} {step.opcode} + {isInline && ( + + ⧉ inline + + )} + {isTailCall && ( + + ⮌ tailcall + + )}
); })} @@ -1065,6 +1112,17 @@ function formatCallBanner(info: CallInfo): string { 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";