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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/programs-react/src/components/CallInfoPanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 11 additions & 6 deletions packages/programs-react/src/components/CallInfoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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";
Expand All @@ -76,9 +83,7 @@ export function CallInfoPanel({

return (
<div className={`call-info-panel ${className}`.trim()}>
<div
className={`call-info-banner ${bannerClassName(currentCallInfo.kind)}`}
>
<div className={`call-info-banner ${bannerClassName(currentCallInfo)}`}>
{formatBanner(currentCallInfo)}
</div>

Expand Down
12 changes: 12 additions & 0 deletions packages/programs-react/src/components/CallStackDisplay.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
8 changes: 8 additions & 0 deletions packages/programs-react/src/components/CallStackDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ export function CallStackDisplay({
<span className="call-stack-parens">
({formatArgs(frame, resolvedCallStack)})
</span>
{frame.isTailCall && (
<span
className="call-stack-tailcall"
title="Tail call: this frame was reused in place (TCO)"
>
⮌ tail call
</span>
)}
</button>
</React.Fragment>
))}
Expand Down
6 changes: 6 additions & 0 deletions packages/programs-react/src/components/TraceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ export interface ResolvedCallInfo {
panic?: number;
/** Resolved pointer refs */
pointerRefs: ResolvedPointerRef[];
/** True when a tailcall transform is present (TCO). */
isTailCall?: boolean;
}

/**
Expand All @@ -136,6 +138,8 @@ export interface ResolvedCallFrame {
value?: string;
error?: string;
}>;
/** True when this frame was (re)entered via a tail call. */
isTailCall?: boolean;
}

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/programs-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export {
findInstructionAtPc,
extractVariablesFromInstruction,
extractCallInfoFromInstruction,
extractTransformFromInstruction,
buildPcToInstructionMap,
buildCallStack,
type CallInfo,
Expand Down
1 change: 1 addition & 0 deletions packages/programs-react/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
findInstructionAtPc,
extractVariablesFromInstruction,
extractCallInfoFromInstruction,
extractTransformFromInstruction,
buildPcToInstructionMap,
buildCallStack,
type TraceStep,
Expand Down
117 changes: 117 additions & 0 deletions packages/programs-react/src/utils/mockTrace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Tests for trace context extraction, transform (tailcall)
* detection, and call-stack construction.
*/

import { describe, it, expect } from "vitest";
import type { Program } from "@ethdebug/format";
import {
extractTransformFromInstruction,
extractCallInfoFromInstruction,
buildCallStack,
buildPcToInstructionMap,
type TraceStep,
} from "./mockTrace.js";

/** Build a minimal instruction with a context at an offset. */
function instr(offset: number, context: unknown): Program.Instruction {
return {
offset,
operation: { mnemonic: "JUMPDEST", arguments: [] },
context,
} 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();
});
});
98 changes: 96 additions & 2 deletions packages/programs-react/src/utils/mockTrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>;

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 [];
}

/**
Expand All @@ -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(
Expand Down Expand Up @@ -274,6 +324,22 @@ 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;
}

/**
* Determine the call type of a raw invoke record from its
* discriminant key.
*/
function invokeCallType(inv: Record<string, unknown>): CallFrame["callType"] {
if ("jump" in inv) return "internal";
if ("message" in inv) return "external";
if ("create" in inv) return "create";
return undefined;
}

/**
Expand All @@ -299,6 +365,34 @@ export function buildCallStack(
continue;
}

if (callInfo.isTailCall) {
// A TCO back-edge carries both return and invoke on a
// single instruction: the previous iteration returns
// and the next iteration is invoked, reusing the same
// activation. Replace the top frame in place (depth is
// unchanged) rather than popping then pushing. Pull the
// new iteration's identity from the invoke leaf, since
// the return leaf may be surfaced first.
const ctx = instruction.context as Record<string, unknown>;
const inv = findInvokeField(ctx);
const argResult = extractArgInfo(instruction);
const invId = inv?.identifier as string | undefined;
const frame: CallFrame = {
identifier: invId ?? callInfo.identifier,
stepIndex: i,
callType: inv ? invokeCallType(inv) : callInfo.callType,
argumentNames: argResult?.names,
argumentPointers: argResult?.pointers,
isTailCall: true,
};
if (stack.length > 0) {
stack[stack.length - 1] = frame;
} else {
stack.push(frame);
}
continue;
}

if (callInfo.kind === "invoke") {
// The compiler emits invoke on both the caller JUMP
// and callee entry JUMPDEST for the same call. These
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/theme/ProgramExample/CallInfoPanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading