diff --git a/packages/programs-react/src/components/TraceContext.test.tsx b/packages/programs-react/src/components/TraceContext.test.tsx new file mode 100644 index 0000000000..cb7eef581f --- /dev/null +++ b/packages/programs-react/src/components/TraceContext.test.tsx @@ -0,0 +1,201 @@ +/** + * Integration tests for TraceProvider's postcondition-aware + * context selection. Instruction contexts are postconditions, so + * the variables/call-info shown at the step about to execute + * instruction i come from instruction i-1 (program-level context + * at the first step). See effectiveContextForStep. + */ + +import { describe, it, expect } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import React from "react"; +import type { Program } from "@ethdebug/format"; +import { TraceProvider, useTraceContext } from "./TraceContext.js"; +import type { TraceStep } from "#utils/mockTrace"; + +function instr(offset: number, context: unknown): Program.Instruction { + return { + offset, + operation: { mnemonic: "JUMPDEST", arguments: [] }, + context, + } as unknown as Program.Instruction; +} + +const program = { + context: { variables: [{ identifier: "prog" }] }, + instructions: [ + instr(0, { variables: [{ identifier: "v0" }] }), + instr(3, { variables: [{ identifier: "v1" }] }), + instr(6, { variables: [{ identifier: "v2" }] }), + ], +} as unknown as Program; + +const trace: TraceStep[] = [ + { pc: 0, opcode: "JUMPDEST" }, + { pc: 3, opcode: "JUMPDEST" }, + { pc: 6, opcode: "JUMPDEST" }, +]; + +// Stable identity so the provider's resolution effects don't +// re-run every render (the default `templates={}` would mint a new +// object each render). +const templates = {}; + +function renderTrace() { + return renderHook(() => useTraceContext(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); +} + +const ids = (vars: { identifier?: string }[]) => vars.map((v) => v.identifier); + +describe("TraceProvider postcondition context selection", () => { + it("shows the program-level context at the first step", () => { + const { result } = renderTrace(); + expect(ids(result.current.currentVariables)).toEqual(["prog"]); + }); + + it("shows the previous instruction's variables after stepping", () => { + const { result } = renderTrace(); + + act(() => result.current.jumpToStep(1)); + // step 1 executes pc=3; observed state is the postcondition of + // pc=0, so the panel shows v0 (NOT v1). + expect(ids(result.current.currentVariables)).toEqual(["v0"]); + + act(() => result.current.jumpToStep(2)); + expect(ids(result.current.currentVariables)).toEqual(["v1"]); + }); +}); + +describe("TraceProvider postcondition call-info selection", () => { + const callProgram = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "sum" } }), + instr(3, { variables: [] }), + ], + } as unknown as Program; + + const callTrace: TraceStep[] = [ + { pc: 0, opcode: "JUMPDEST" }, + { pc: 3, opcode: "JUMPDEST" }, + ]; + + function render() { + return renderHook(() => useTraceContext(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + } + + it("does not show the invoke while parked on the invoke instruction", () => { + const { result } = render(); + // step 0 is about to execute pc=0 (the invoke); it has not run + // yet, so no call info is shown. + expect(result.current.currentCallInfo).toBeUndefined(); + }); + + it("shows the invoke once its instruction has executed", () => { + const { result } = render(); + act(() => result.current.jumpToStep(1)); + // step 1 observes the postcondition of pc=0, so the invoke of + // "sum" surfaces here. + expect(result.current.currentCallInfo?.kind).toBe("invoke"); + expect(result.current.currentCallInfo?.identifier).toBe("sum"); + }); +}); + +describe("TraceProvider call-stack timing", () => { + // A real call as the compiler emits it: invoke on the caller + // JUMP and on the callee entry JUMPDEST (with the argument + // pointers), then the callee body. The JUMP pops its target, so + // the argument's stack slot only holds the argument once the + // JUMPDEST is reached; JUMPDEST itself is a no-op, so the state + // observed at the JUMPDEST and at the step after it coincide. + const callProgram = { + instructions: [ + instr(0, { invoke: { jump: true, identifier: "f" } }), + instr(1, { + invoke: { + jump: true, + identifier: "f", + arguments: { + pointer: { group: [{ name: "x", location: "stack", slot: 0 }] }, + }, + }, + }), + instr(2, { code: {} }), + ], + } as unknown as Program; + + // Stack entries are listed bottom-to-top. + const callTrace: TraceStep[] = [ + { pc: 0, opcode: "JUMP", stack: ["0x2a", "0x01"] }, + { pc: 1, opcode: "JUMPDEST", stack: ["0x2a"] }, + { pc: 2, opcode: "PUSH1", stack: ["0x2a"] }, + ]; + + function render() { + return renderHook(() => useTraceContext(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + } + + it("shows the frame and the invoke banner on the same step", () => { + const { result } = render(); + // Parked on the caller JUMP: neither the banner nor the frame + // list shows the call yet. + expect(result.current.currentCallInfo).toBeUndefined(); + expect(result.current.callStack).toHaveLength(0); + + act(() => result.current.jumpToStep(1)); + expect(result.current.currentCallInfo?.kind).toBe("invoke"); + expect(result.current.callStack).toHaveLength(1); + expect(result.current.callStack[0].identifier).toBe("f"); + }); + + it("resolves arguments against the entry's postcondition", async () => { + const { result } = render(); + act(() => result.current.jumpToStep(2)); + + // The frame is rooted at the step after the JUMPDEST, which is + // where its argument pointers describe the observed state. + expect(result.current.callStack[0].stepIndex).toBe(2); + expect(result.current.callStack[0].argumentNames).toEqual(["x"]); + + await waitFor(() => { + const args = result.current.resolvedCallStack[0]?.resolvedArgs; + expect(args?.[0]?.value).toBeDefined(); + }); + const [x] = result.current.resolvedCallStack[0].resolvedArgs!; + expect(x.name).toBe("x"); + expect(x.error).toBeUndefined(); + expect(BigInt(x.value!)).toBe(42n); + }); +}); diff --git a/packages/programs-react/src/components/TraceContext.tsx b/packages/programs-react/src/components/TraceContext.tsx index 795f3f2f44..943a3eec44 100644 --- a/packages/programs-react/src/components/TraceContext.tsx +++ b/packages/programs-react/src/components/TraceContext.tsx @@ -23,6 +23,7 @@ import { buildCallStack, } from "#utils/mockTrace"; import { traceStepToMachineState } from "#utils/traceState"; +import { effectiveContextForStep } from "#utils/effectiveContext"; /** * Compute a key representing an instruction's source range, @@ -282,13 +283,39 @@ export function TraceProvider({ ? pcToInstruction.get(currentStep.pc) : undefined; + // Instruction contexts are POSTCONDITIONS: the semantic facts and + // pointers shown at the step about to execute instruction i come + // from instruction i-1 (program-level context at the first step). + // Pointer resolution still runs against the state observed at step + // i; only the context selection shifts. See effectiveContextForStep. + const effectiveContext = useMemo( + () => + effectiveContextForStep({ + programContext: program.context, + contextAtPc: (pc) => pcToInstruction.get(pc)?.context, + trace, + stepIndex: currentStepIndex, + }), + [program.context, pcToInstruction, trace, currentStepIndex], + ); + + // A synthetic instruction lets the context-tree extractors (which + // read `.context`) apply uniformly to the program-level base case. + const effectiveInstruction = useMemo( + () => + effectiveContext + ? ({ context: effectiveContext } as Program.Instruction) + : undefined, + [effectiveContext], + ); + // Extract variable metadata (synchronous) const extractedVars = useMemo(() => { - if (!currentInstruction) { + if (!effectiveInstruction) { return []; } - return extractVariablesFromInstruction(currentInstruction); - }, [currentInstruction]); + return extractVariablesFromInstruction(effectiveInstruction); + }, [effectiveInstruction]); // Async variable resolution const [currentVariables, setCurrentVariables] = useState( @@ -358,13 +385,19 @@ export function TraceProvider({ }; }, [extractedVars, currentStep, shouldResolve, templates]); - // Build call stack by scanning instructions up to current step + // Build the call stack on the same postcondition timing as the + // panels above: at step i the frame list reflects instructions + // 0..i-1 (program-level context as the base case), so a frame + // appears on the step the call-info banner first names its invoke. const callStack = useMemo( - () => buildCallStack(trace, pcToInstruction, currentStepIndex), - [trace, pcToInstruction, currentStepIndex], + () => + buildCallStack(trace, pcToInstruction, currentStepIndex, program.context), + [trace, pcToInstruction, currentStepIndex, program.context], ); - // Resolve argument values for call stack frames. + // Resolve argument values for call stack frames. A frame's + // stepIndex is the step whose observed state its argument + // pointers describe (the callee entry's postcondition). // Cache by stepIndex so we don't re-resolve frames that // haven't changed when the user steps forward. const argCacheRef = useRef>( @@ -456,12 +489,12 @@ export function TraceProvider({ }; }, [callStack, shouldResolve, trace, templates]); - // Extract call info for current instruction (synchronous) + // Extract call info from the effective (postcondition) context. const extractedCallInfo = useMemo((): CallInfo | undefined => { - if (!currentInstruction) { + if (!effectiveInstruction) { return undefined; } - return extractCallInfoFromInstruction(currentInstruction); + return extractCallInfoFromInstruction(effectiveInstruction); }, [currentInstruction]); // Async call info pointer resolution diff --git a/packages/programs-react/src/index.ts b/packages/programs-react/src/index.ts index 8d86107711..0ce79eb4bf 100644 --- a/packages/programs-react/src/index.ts +++ b/packages/programs-react/src/index.ts @@ -70,6 +70,8 @@ export { type FindSourceRangeOptions, type ResolverOptions, traceStepToMachineState, + effectiveContextForStep, + type EffectiveContextInput, type TraceStep, type MockTraceSpec, } from "#utils/index"; diff --git a/packages/programs-react/src/utils/effectiveContext.test.ts b/packages/programs-react/src/utils/effectiveContext.test.ts new file mode 100644 index 0000000000..ea92c6dd7b --- /dev/null +++ b/packages/programs-react/src/utils/effectiveContext.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for effectiveContextForStep — the postcondition-aware + * selection of which instruction context describes the machine + * state observed at a given trace step. + * + * Instruction contexts are POSTCONDITIONS: instruction i's + * context describes the state AFTER i executes. A debugger + * paused at the trace step about to execute instruction i is + * observing the state produced by instruction i-1, so it must + * apply instruction i-1's context. Program-level context is the + * base case for the first step. + */ + +import { describe, it, expect } from "vitest"; +import type { Program } from "@ethdebug/format"; +import { effectiveContextForStep } from "./effectiveContext.js"; + +const programContext = { + code: { range: { offset: 0, length: 1 } }, +} as Program.Context; +const ctxAt10 = { + variables: [{ identifier: "a" }], +} as unknown as Program.Context; +const ctxAt20 = { + variables: [{ identifier: "b" }], +} as unknown as Program.Context; + +const contextByPc = new Map([ + [10, ctxAt10], + [20, ctxAt20], +]); +const contextAtPc = (pc: number) => contextByPc.get(pc); + +const trace = [{ pc: 10 }, { pc: 20 }]; + +describe("effectiveContextForStep", () => { + it("returns the program-level context at the first step", () => { + const result = effectiveContextForStep({ + programContext, + contextAtPc, + trace, + stepIndex: 0, + }); + expect(result).toBe(programContext); + }); + + it("returns undefined at the first step when there is no program context", () => { + const result = effectiveContextForStep({ + programContext: undefined, + contextAtPc, + trace, + stepIndex: 0, + }); + expect(result).toBeUndefined(); + }); + + it("returns the PREVIOUS step's instruction context, not the current step's", () => { + const result = effectiveContextForStep({ + programContext, + contextAtPc, + trace, + stepIndex: 1, + }); + // step 1 executes pc=20; the observed state is the postcondition + // of pc=10, so context(pc=10) must be returned. + expect(result).toBe(ctxAt10); + expect(result).not.toBe(ctxAt20); + }); + + it("returns undefined when the previous step's instruction has no context", () => { + const result = effectiveContextForStep({ + programContext, + contextAtPc: () => undefined, + trace, + stepIndex: 1, + }); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/programs-react/src/utils/effectiveContext.ts b/packages/programs-react/src/utils/effectiveContext.ts new file mode 100644 index 0000000000..408b9f24fc --- /dev/null +++ b/packages/programs-react/src/utils/effectiveContext.ts @@ -0,0 +1,60 @@ +/** + * Postcondition-aware selection of the instruction context that + * describes the machine state observed at a given trace step. + * + * Instruction contexts are POSTCONDITIONS: instruction i's + * context — its semantic facts AND its pointers — describes the + * machine state AFTER i executes. A trace step observes the state + * BEFORE its instruction executes, so the step about to execute + * instruction i is observing the postcondition of instruction + * i-1. The consumer rule is therefore: prepend the program-level + * context and index the resulting sequence by trace position — + * i.e. apply instruction (i-1)'s context at step i, with the + * program-level context as the base case for the first step. + * + * Pointer resolution still runs against the state observed at + * step i; only the CONTEXT selection shifts. + */ + +import type { Program } from "@ethdebug/format"; + +/** + * Inputs for {@link effectiveContextForStep}. The context source + * is supplied as an accessor so callers with different + * instruction shapes (e.g. `instruction.context` vs + * `instruction.debug.context`) can share this logic. + */ +export interface EffectiveContextInput { + /** Program-level context (base case for the first step). */ + programContext?: Program.Context; + /** Resolve the context carried by the instruction at a pc. */ + contextAtPc(pc: number): Program.Context | undefined; + /** The trace, indexed by step position. */ + trace: ReadonlyArray<{ pc: number }>; + /** The current step position. */ + stepIndex: number; +} + +/** + * Return the context describing the state observed at + * `stepIndex`: the program-level context at the first step, + * otherwise the context of the instruction executed at the + * previous step. + */ +export function effectiveContextForStep({ + programContext, + contextAtPc, + trace, + stepIndex, +}: EffectiveContextInput): Program.Context | undefined { + if (stepIndex <= 0) { + return programContext; + } + + const previous = trace[stepIndex - 1]; + if (!previous) { + return programContext; + } + + return contextAtPc(previous.pc); +} diff --git a/packages/programs-react/src/utils/index.ts b/packages/programs-react/src/utils/index.ts index e6dfdbefb7..881167bb3e 100644 --- a/packages/programs-react/src/utils/index.ts +++ b/packages/programs-react/src/utils/index.ts @@ -28,3 +28,8 @@ export { } from "./mockTrace.js"; export { traceStepToMachineState } from "./traceState.js"; + +export { + effectiveContextForStep, + type EffectiveContextInput, +} from "./effectiveContext.js"; diff --git a/packages/programs-react/src/utils/mockTrace.test.ts b/packages/programs-react/src/utils/mockTrace.test.ts index 21ae3e431f..1e5517b781 100644 --- a/packages/programs-react/src/utils/mockTrace.test.ts +++ b/packages/programs-react/src/utils/mockTrace.test.ts @@ -3,6 +3,12 @@ * 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. + * + * Call-stack timing: instruction contexts are postconditions, so + * the frame list at trace step i reflects the instructions + * executed at steps 0..i-1. An invoke on the instruction at step + * k opens its frame at step k+1; a return at step k is still shown + * at step k+1 (close-after) and gone at step k+2. */ import { describe, it, expect } from "vitest"; @@ -15,6 +21,7 @@ import { buildPcToInstructionMap, type TraceStep, } from "./mockTrace.js"; +import { effectiveContextForStep } from "./effectiveContext.js"; /** Build a minimal instruction with a context at an offset. */ function instr(offset: number, context: unknown): Program.Instruction { @@ -80,13 +87,15 @@ describe("extractCallInfoFromInstruction tailcall flag", () => { 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 + { pc: 0, opcode: "JUMPDEST" }, // entry invoke (push at step 1) + { pc: 10, opcode: "JUMP" }, // TCO back-edge (replace at step 2) + { pc: 4, opcode: "JUMPDEST" }, // loop body ]; const program = { instructions: [ instr(0, { invoke: { jump: true, identifier: "sum" } }), + instr(4, { code: {} }), instr(10, { gather: [ { return: { identifier: "sum" } }, @@ -100,20 +109,20 @@ describe("buildCallStack TCO frame replacement", () => { const pcToInstruction = buildPcToInstructionMap(program); it("keeps the stack depth stable across a tail call", () => { - const stack = buildCallStack(trace, pcToInstruction, 1); + const stack = buildCallStack(trace, pcToInstruction, 2); // 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); + const stack = buildCallStack(trace, pcToInstruction, 2); expect(stack[0].identifier).toBe("sum"); expect(stack[0].isTailCall).toBe(true); - expect(stack[0].stepIndex).toBe(1); + expect(stack[0].stepIndex).toBe(2); }); it("does not mark a normal (pre-tailcall) frame", () => { - const stack = buildCallStack(trace, pcToInstruction, 0); + const stack = buildCallStack(trace, pcToInstruction, 1); expect(stack).toHaveLength(1); expect(stack[0].isTailCall).toBeFalsy(); }); @@ -146,34 +155,36 @@ describe("buildCallStack flat return+invoke back-edge", () => { // caller-JUMP/callee-JUMPDEST dedup does not apply — this is // the arrangement that exposes the bug. const trace: TraceStep[] = [ - { pc: 0, opcode: "JUMPDEST" }, // step 0: push sum + { pc: 0, opcode: "JUMPDEST" }, // step 0: entry (push at step 1) { pc: 4, opcode: "JUMPDEST" }, // step 1: loop body - { pc: 10, opcode: "JUMP" }, // step 2: back-edge → reuse + { pc: 10, opcode: "JUMP" }, // step 2: back-edge (reuse at step 3) { pc: 4, opcode: "JUMPDEST" }, // step 3: loop body - { pc: 10, opcode: "JUMP" }, // step 4: back-edge → reuse + { pc: 10, opcode: "JUMP" }, // step 4: back-edge (reuse at step 5) + { pc: 4, opcode: "JUMPDEST" }, // step 5: loop body ]; it("keeps the stack at constant depth across the back-edge", () => { // Reused in place: one frame in, one frame out — depth 1. - expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(1); - expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(1); + expect(buildCallStack(trace, pcToInstruction, 3)).toHaveLength(1); + expect(buildCallStack(trace, pcToInstruction, 5)).toHaveLength(1); }); it("reuses the top frame with the next iteration's identity", () => { - const stack = buildCallStack(trace, pcToInstruction, 2); + const stack = buildCallStack(trace, pcToInstruction, 3); expect(stack[0].identifier).toBe("sum"); expect(stack[0].callType).toBe("internal"); - // Points at the back-edge step, not the original entry. - expect(stack[0].stepIndex).toBe(2); + // Rooted at the back-edge's postcondition step, not the + // original entry's. + expect(stack[0].stepIndex).toBe(3); }); it("still pushes and pops ordinary (non-flat) calls", () => { // A normal invoke on one instruction, a normal return on // 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. + // close-after semantics: the frame stays visible on the step + // that observes the return's postcondition and is popped only + // once execution advances past it. const normalProgram = { instructions: [ instr(0, { invoke: { jump: true, identifier: "helper" } }), @@ -182,16 +193,19 @@ describe("buildCallStack flat return+invoke back-edge", () => { } as unknown as Program; const map = buildPcToInstructionMap(normalProgram); const normalTrace: TraceStep[] = [ - { pc: 0, opcode: "JUMPDEST" }, // invoke helper → push - { pc: 8, opcode: "JUMP" }, // return helper (close-after) - { pc: 12, opcode: "STOP" }, // advanced past the return + { pc: 0, opcode: "JUMPDEST" }, // invoke helper + { pc: 8, opcode: "JUMP" }, // return helper + { pc: 12, opcode: "JUMPDEST" }, // back in the caller + { pc: 13, opcode: "STOP" }, ]; - // Pushed on the invoke. - expect(buildCallStack(normalTrace, map, 0)).toHaveLength(1); - // Still visible while parked on the return (close-after). + // Not yet pushed: the invoke has not executed. + expect(buildCallStack(normalTrace, map, 0)).toHaveLength(0); + // Pushed once the invoke has executed. expect(buildCallStack(normalTrace, map, 1)).toHaveLength(1); + // Still visible on the return's postcondition step (close-after). + expect(buildCallStack(normalTrace, map, 2)).toHaveLength(1); // Popped once execution advances past the return. - expect(buildCallStack(normalTrace, map, 2)).toHaveLength(0); + expect(buildCallStack(normalTrace, map, 3)).toHaveLength(0); }); }); @@ -225,6 +239,7 @@ describe("flat (production) TCO back-edge shape", () => { const trace: TraceStep[] = [ { pc: 0, opcode: "JUMPDEST" }, { pc: 10, opcode: "JUMP" }, + { pc: 4, opcode: "JUMPDEST" }, ]; const program = { instructions: [ @@ -234,7 +249,7 @@ describe("flat (production) TCO back-edge shape", () => { } as unknown as Program; const pcToInstruction = buildPcToInstructionMap(program); - const stack = buildCallStack(trace, pcToInstruction, 1); + const stack = buildCallStack(trace, pcToInstruction, 2); expect(stack).toHaveLength(1); expect(stack[0].identifier).toBe("sum"); expect(stack[0].isTailCall).toBe(true); @@ -255,6 +270,7 @@ describe("flat (production) TCO back-edge shape", () => { const trace: TraceStep[] = [ { pc: 0, opcode: "JUMPDEST" }, { pc: 10, opcode: "JUMP" }, + { pc: 4, opcode: "JUMPDEST" }, ]; const program = { instructions: [ @@ -264,7 +280,7 @@ describe("flat (production) TCO back-edge shape", () => { } as unknown as Program; const pcToInstruction = buildPcToInstructionMap(program); - const stack = buildCallStack(trace, pcToInstruction, 1); + const stack = buildCallStack(trace, pcToInstruction, 2); expect(stack.some((f) => f.isTailCall)).toBe(false); }); }); @@ -275,12 +291,13 @@ describe("flat (production) TCO back-edge shape", () => { // 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. +// push/pop (a frame is visible on the step that observes its +// return's postcondition and popped on advance), tags it, and — +// belt-and-suspenders — tears down any trailing virtual frame the +// moment execution has passed 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 } }, @@ -350,10 +367,11 @@ describe("inline virtual activations", () => { 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: 0, opcode: "PUSH1" }, // entry invoke (push at step 1) { pc: 1, opcode: "ADD" }, // inlined body instruction - { pc: 2, opcode: "MSTORE" }, // exit return (still inside frame) - { pc: 3, opcode: "JUMPDEST" }, // caller code (frame gone) + { pc: 2, opcode: "MSTORE" }, // exit return (pop at step 4) + { pc: 3, opcode: "JUMPDEST" }, // caller code + { pc: 4, opcode: "STOP" }, ]; const program = { instructions: [ @@ -365,27 +383,28 @@ describe("inline virtual activations", () => { } as unknown as Program; const pcToInstruction = buildPcToInstructionMap(program); - it("pushes a virtual frame tagged isInline at the entry", () => { - const stack = buildCallStack(trace, pcToInstruction, 0); + it("pushes a virtual frame tagged isInline after the entry", () => { + expect(buildCallStack(trace, pcToInstruction, 0)).toHaveLength(0); + const stack = buildCallStack(trace, pcToInstruction, 1); 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); + const stack = buildCallStack(trace, pcToInstruction, 2); 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); + it("still shows the frame after the exit return (close-after)", () => { + const stack = buildCallStack(trace, pcToInstruction, 3); 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); + const stack = buildCallStack(trace, pcToInstruction, 4); expect(stack).toHaveLength(0); }); }); @@ -394,20 +413,21 @@ describe("inline virtual activations", () => { const trace: TraceStep[] = [ { pc: 0, opcode: "PUSH1" }, // the whole body: invoke+return { pc: 1, opcode: "JUMPDEST" }, // caller code + { pc: 2, opcode: "STOP" }, ]; 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); + it("shows the virtual frame after the single body op", () => { + const stack = buildCallStack(trace, pcToInstruction, 1); 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); + it("pops after advancing further", () => { + expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(0); }); }); @@ -419,6 +439,7 @@ describe("inline virtual activations", () => { { pc: 10, opcode: "PUSH1" }, // site 2 entry { pc: 12, opcode: "MSTORE" }, // site 2 exit { pc: 13, opcode: "JUMPDEST" }, // caller + { pc: 14, opcode: "STOP" }, ]; const program = { instructions: [ @@ -433,14 +454,14 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("shows depth 1 while inside the second body", () => { - const stack = buildCallStack(trace, pcToInstruction, 3); + const stack = buildCallStack(trace, pcToInstruction, 4); expect(stack).toHaveLength(1); expect(stack[0].isInline).toBe(true); - expect(stack[0].stepIndex).toBe(3); + expect(stack[0].stepIndex).toBe(4); }); it("is empty after both sites — no accumulation", () => { - const stack = buildCallStack(trace, pcToInstruction, 5); + const stack = buildCallStack(trace, pcToInstruction, 6); expect(stack).toHaveLength(0); }); }); @@ -456,6 +477,7 @@ describe("inline virtual activations", () => { { pc: 2, opcode: "PUSH1" }, // site 2 entry (immediately) { pc: 3, opcode: "MSTORE" }, // site 2 exit { pc: 5, opcode: "JUMPDEST" }, // caller + { pc: 6, opcode: "STOP" }, ]; const program = { instructions: [ @@ -469,13 +491,13 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("does not merge or accumulate — one frame, rooted at site 2", () => { - const stack = buildCallStack(trace, pcToInstruction, 2); + const stack = buildCallStack(trace, pcToInstruction, 3); expect(stack).toHaveLength(1); - expect(stack[0].stepIndex).toBe(2); + expect(stack[0].stepIndex).toBe(3); }); it("is empty after both sites", () => { - expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(0); + expect(buildCallStack(trace, pcToInstruction, 5)).toHaveLength(0); }); }); @@ -487,6 +509,7 @@ describe("inline virtual activations", () => { const trace: TraceStep[] = [ { pc: 0, opcode: "JUMP" }, // real invoke of dbl { pc: 1, opcode: "PUSH1" }, // virtual (inline) invoke of dbl + { pc: 2, opcode: "ADD" }, ]; const program = { instructions: [instr(0, realInvoke), instr(1, entryInvoke)], @@ -494,7 +517,7 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("keeps a real and a virtual dbl as two separate frames", () => { - const stack = buildCallStack(trace, pcToInstruction, 1); + const stack = buildCallStack(trace, pcToInstruction, 2); expect(stack).toHaveLength(2); expect(stack[0].isInline).toBeFalsy(); expect(stack[1].isInline).toBe(true); @@ -524,6 +547,7 @@ describe("inline virtual activations", () => { { pc: 1, opcode: "PUSH1" }, // enter A (inside B) { pc: 2, opcode: "MSTORE" }, // exit A { pc: 3, opcode: "ADD" }, // back in B only + { pc: 4, opcode: "STOP" }, ]; const program = { instructions: [ @@ -536,14 +560,14 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("stacks two virtual frames inside the inner body", () => { - const stack = buildCallStack(trace, pcToInstruction, 1); + const stack = buildCallStack(trace, pcToInstruction, 2); 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); + const stack = buildCallStack(trace, pcToInstruction, 4); expect(stack).toHaveLength(1); expect(stack[0].identifier).toBe("B"); }); @@ -552,12 +576,13 @@ describe("inline virtual activations", () => { 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, + // once execution has passed 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: 0, opcode: "PUSH1" }, // virtual invoke (push at step 1) { pc: 1, opcode: "ADD" }, // still inside the body { pc: 3, opcode: "JUMPDEST" }, // caller code, no inline marker + { pc: 4, opcode: "STOP" }, ]; const program = { instructions: [ @@ -569,23 +594,25 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("keeps the frame while inline membership holds", () => { - expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(1); + expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(1); }); - it("force-pops a stale virtual frame at a non-inline instr", () => { - expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(0); + it("force-pops a stale virtual frame past a non-inline instr", () => { + expect(buildCallStack(trace, pcToInstruction, 3)).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. + // return. The frame is visible on the return's postcondition + // 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 + { pc: 4, opcode: "STOP" }, ]; const program = { instructions: [ @@ -598,17 +625,17 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("collapses the caller/callee invoke double into one frame", () => { - expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(1); + expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(1); }); - it("still shows the frame AT its return instruction", () => { - const stack = buildCallStack(trace, pcToInstruction, 2); + it("still shows the frame after its return instruction", () => { + const stack = buildCallStack(trace, pcToInstruction, 3); 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); + expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(0); }); }); @@ -630,6 +657,7 @@ describe("inline virtual activations", () => { { pc: 2, opcode: "ADD" }, // interior op { pc: 3, opcode: "MSTORE" }, // exit op (return) { pc: 4, opcode: "JUMPDEST" }, // gap / caller + { pc: 5, opcode: "STOP" }, ]; const program = { instructions: [ @@ -643,7 +671,8 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("shows the virtual frame across every body op incl. the exit", () => { - for (const s of [0, 1, 2, 3]) { + expect(buildCallStack(trace, pcToInstruction, 0)).toHaveLength(0); + for (const s of [1, 2, 3, 4]) { const stack = buildCallStack(trace, pcToInstruction, s); expect(stack).toHaveLength(1); expect(stack[0].isInline).toBe(true); @@ -651,7 +680,7 @@ describe("inline virtual activations", () => { }); it("is gone at the gap after the return op", () => { - expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(0); + expect(buildCallStack(trace, pcToInstruction, 5)).toHaveLength(0); }); }); @@ -676,6 +705,7 @@ describe("inline virtual activations", () => { { pc: 5, opcode: "DUP2" }, { pc: 6, opcode: "MSTORE" }, { pc: 7, opcode: "JUMPDEST" }, // gap + { pc: 8, opcode: "STOP" }, ]; const program = { instructions: [ @@ -692,7 +722,7 @@ describe("inline virtual activations", () => { const pcToInstruction = buildPcToInstructionMap(program); it("shows exactly one frame across each smeared body", () => { - for (const s of [0, 1, 2, 4, 5, 6]) { + for (const s of [1, 2, 3, 5, 6, 7]) { const stack = buildCallStack(trace, pcToInstruction, s); expect(stack).toHaveLength(1); expect(stack[0].isInline).toBe(true); @@ -700,8 +730,143 @@ describe("inline virtual activations", () => { }); it("returns to top level at each gap — no accumulation", () => { - expect(buildCallStack(trace, pcToInstruction, 3)).toHaveLength(0); - expect(buildCallStack(trace, pcToInstruction, 7)).toHaveLength(0); + expect(buildCallStack(trace, pcToInstruction, 4)).toHaveLength(0); + expect(buildCallStack(trace, pcToInstruction, 8)).toHaveLength(0); + }); + }); +}); + +// Instruction contexts are POSTCONDITIONS: at trace step i the +// frame list must reflect instructions 0..i-1 (program-level +// context as the base case), so that it agrees at every step with +// the call-info banner, which is selected by effectiveContextForStep +// under the same rule. +describe("buildCallStack postcondition timing", () => { + // A real call as the compiler emits it: invoke on the caller JUMP + // AND the callee entry JUMPDEST (which also carries the argument + // pointers), then a return on the callee's exit JUMP. + const argPointer = { name: "x", location: "stack", slot: 0 }; + const program = { + instructions: [ + instr(0, { code: {} }), // caller prologue + instr(1, { invoke: { jump: true, identifier: "f" } }), // caller JUMP + instr(2, { + invoke: { + jump: true, + identifier: "f", + arguments: { pointer: { group: [argPointer] } }, + }, + }), // callee entry JUMPDEST + instr(3, { code: {} }), // callee body + instr(4, { return: { identifier: "f" } }), // callee exit JUMP + instr(5, { code: {} }), // back in the caller + instr(6, { code: {} }), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + const trace: TraceStep[] = [0, 1, 2, 3, 4, 5, 6].map((pc) => ({ + pc, + opcode: "JUMPDEST", + })); + + /** The call-info banner's selection for a step. */ + const banner = (stepIndex: number) => { + const context = effectiveContextForStep({ + programContext: program.context, + contextAtPc: (pc) => pcToInstruction.get(pc)?.context, + trace, + stepIndex, }); + return context + ? extractCallInfoFromInstruction(instr(0, context)) + : undefined; + }; + + it("shows no frame while parked on the caller JUMP", () => { + // Step 1 is ABOUT to execute the invoke; it has not run yet, so + // neither the banner nor the frame list may show it. + expect(banner(1)).toBeUndefined(); + expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(0); + }); + + it("pushes the frame on the step the banner first names the invoke", () => { + expect(banner(2)?.kind).toBe("invoke"); + const stack = buildCallStack(trace, pcToInstruction, 2); + expect(stack).toHaveLength(1); + expect(stack[0].identifier).toBe("f"); + expect(stack[0].stepIndex).toBe(2); + }); + + it("agrees with the banner at every step", () => { + const depths = trace.map( + (_, i) => buildCallStack(trace, pcToInstruction, i).length, + ); + const kinds = trace.map((_, i) => banner(i)?.kind); + // The frame opens with the invoke's postcondition and — under + // close-after — is still shown alongside the return banner, + // vanishing on the step after. + expect(kinds).toEqual([ + undefined, + undefined, + "invoke", + "invoke", + undefined, + "return", + undefined, + ]); + expect(depths).toEqual([0, 0, 1, 1, 1, 1, 0]); + }); + + it("roots the frame at the step after the callee entry", () => { + // The dedup keeps the callee JUMPDEST's argument pointers, and + // those are its postcondition — they describe the state observed + // at the step AFTER it, which is where the frame is rooted. + const stack = buildCallStack(trace, pcToInstruction, 4); + expect(stack[0].stepIndex).toBe(3); + expect(stack[0].argumentNames).toEqual(["x"]); + expect(stack[0].argumentPointers).toEqual([argPointer]); + }); + + it("opens a frame from the program-level context at the first step", () => { + const entryProgram = { + context: { invoke: { jump: true, identifier: "main" } }, + instructions: [instr(0, { code: {} })], + } as unknown as Program; + const stack = buildCallStack( + [{ pc: 0, opcode: "JUMPDEST" }], + buildPcToInstructionMap(entryProgram), + 0, + entryProgram.context, + ); + expect(stack).toHaveLength(1); + expect(stack[0].identifier).toBe("main"); + expect(stack[0].stepIndex).toBe(0); + }); +}); + +describe("buildCallStack membership guard on context-less steps", () => { + // A virtual invoke whose exit return never arrives, followed by + // an instruction carrying no context at all: no context means no + // inline membership, so the stale virtual frame is torn down just + // as it would be at a context-bearing caller instruction. + const trace: TraceStep[] = [ + { pc: 0, opcode: "PUSH1" }, // virtual invoke (push at step 1) + { pc: 1, opcode: "JUMPDEST" }, // no context + { pc: 2, opcode: "STOP" }, + ]; + const program = { + instructions: [ + instr(0, { + transform: ["inline"], + invoke: { jump: true, identifier: "dbl" }, + }), + instr(1, undefined), + ], + } as unknown as Program; + const pcToInstruction = buildPcToInstructionMap(program); + + it("force-pops the stale virtual frame", () => { + expect(buildCallStack(trace, pcToInstruction, 1)).toHaveLength(1); + expect(buildCallStack(trace, pcToInstruction, 2)).toHaveLength(0); }); }); diff --git a/packages/programs-react/src/utils/mockTrace.ts b/packages/programs-react/src/utils/mockTrace.ts index e4acbd5059..670f212bb9 100644 --- a/packages/programs-react/src/utils/mockTrace.ts +++ b/packages/programs-react/src/utils/mockTrace.ts @@ -3,6 +3,7 @@ */ import { Program } from "@ethdebug/format"; +import { effectiveContextForStep } from "./effectiveContext.js"; /** * A single step in an execution trace. @@ -199,11 +200,15 @@ export function extractCallEvents( if (!instruction.context) { return []; } - const events = collectCallInfos(instruction.context); + return extractCallEventsFromContext(instruction.context); +} + +function extractCallEventsFromContext(context: Program.Context): CallInfo[] { + const events = collectCallInfos(context); if (events.length === 0) { return []; } - const transforms = extractTransformFromContext(instruction.context); + const transforms = extractTransformFromContext(context); const isTailCall = transforms.includes("tailcall"); const isInline = transforms.includes("inline"); if (!isTailCall && !isInline) { @@ -369,29 +374,43 @@ export interface CallFrame { } /** - * Build a call stack by scanning instructions from - * step 0 to the given step index. + * Build the call stack as observed at a trace step. + * + * Instruction contexts are POSTCONDITIONS, so the frame list at + * step i reflects the contexts of the instructions executed at + * steps 0..i-1, with the program-level context as the base case + * (see {@link effectiveContextForStep}). A frame therefore opens + * on the step AFTER its invoke instruction — the same step on + * which the call-info banner first names the invoke — and a + * frame's `stepIndex` is the step whose observed state its + * argument pointers describe. */ export function buildCallStack( trace: TraceStep[], pcToInstruction: Map, upToStep: number, + programContext?: Program.Context, ): CallFrame[] { const stack: CallFrame[] = []; + const contextAtPc = (pc: number) => pcToInstruction.get(pc)?.context; for (let i = 0; i <= upToStep && i < trace.length; i++) { - const step = trace[i]; - const instruction = pcToInstruction.get(step.pc); - if (!instruction) { - continue; - } + // A step without a context contributes no call events and no + // inline membership; the membership guard below still applies. + const context = effectiveContextForStep({ + programContext, + contextAtPc, + trace, + stepIndex: i, + }); + const ctx = context as unknown as Record | undefined; // 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 transforms = context ? extractTransformFromContext(context) : []; const inlineCount = transforms.filter((t) => t === "inline").length; // A tail-call back-edge carries both a `return` (the previous @@ -406,7 +425,6 @@ export function buildCallStack( // 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 && @@ -414,7 +432,7 @@ export function buildCallStack( hasReturnContext(ctx) && !transforms.includes("inline") ) { - const argResult = extractArgInfo(instruction); + const argResult = extractArgInfo(ctx); const frame: CallFrame = { identifier: backEdgeInvoke.identifier as string | undefined, stepIndex: i, @@ -434,10 +452,11 @@ export function buildCallStack( // 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)) { + // its step; a return closes it AFTER its step (close-after) — + // so the frame is still shown on the step whose banner names + // the return and popped only on advance. + const events = context ? extractCallEventsFromContext(context) : []; + for (const event of events) { if (event.kind === "invoke") { // The compiler emits invoke on both the caller JUMP and // callee entry JUMPDEST for a REAL call, on consecutive @@ -452,14 +471,15 @@ export function buildCallStack( 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); + // Root the frame at the callee entry's postcondition step: + // the argument pointers/names live on the JUMPDEST, not + // the JUMP, and describe the state observed after it. + const argResult = ctx ? extractArgInfo(ctx) : undefined; top.stepIndex = i; top.argumentNames = argResult?.names ?? top.argumentNames; top.argumentPointers = argResult?.pointers; } else { - const argResult = extractArgInfo(instruction); + const argResult = ctx ? extractArgInfo(ctx) : undefined; stack.push({ identifier: event.identifier, stepIndex: i, @@ -503,15 +523,12 @@ export function buildCallStack( } /** - * Extract argument names and pointers from an - * instruction's invoke context, if present. + * Extract argument names and pointers from a context's invoke, + * if present. */ function extractArgInfo( - instruction: Program.Instruction, + ctx: Record, ): { names?: string[]; pointers?: unknown[] } | undefined { - const ctx = instruction.context as Record | undefined; - if (!ctx) return undefined; - const invoke = findInvokeField(ctx); if (!invoke) return undefined; diff --git a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx index 1d734c43f3..24923b0192 100644 --- a/packages/web/src/theme/ProgramExample/TraceDrawer.tsx +++ b/packages/web/src/theme/ProgramExample/TraceDrawer.tsx @@ -27,6 +27,7 @@ import { Executor, createTraceCollector, type TraceStep } from "@ethdebug/evm"; import { dereference, Data, type Machine } from "@ethdebug/pointers"; import { buildCallStack, + effectiveContextForStep, extractCallInfoFromInstruction, extractTransformFromInstruction, type CallFrame, @@ -183,16 +184,27 @@ function TraceDrawerContent(): JSX.Element { return extractSourceRange(instruction.debug.context); }, [trace, currentStep, pcToInstruction]); - // Extract variables from current instruction context - const currentVariables = useMemo(() => { - if (trace.length === 0 || currentStep >= trace.length) return []; - - const step = trace[currentStep]; - const instruction = pcToInstruction.get(step.pc); - if (!instruction?.debug?.context) return []; + // Instruction contexts are POSTCONDITIONS, so the semantic facts + // shown at the step about to execute instruction i come from + // instruction i-1 (bugc emits no program-level context, so the + // first step is empty). Pointer resolution still runs against the + // state observed at step i; only the context selection shifts. + const effectiveContext = useMemo( + () => + effectiveContextForStep({ + programContext: undefined, + contextAtPc: (pc) => pcToInstruction.get(pc)?.debug?.context, + trace, + stepIndex: currentStep, + }), + [pcToInstruction, trace, currentStep], + ); - return extractVariables(instruction.debug.context); - }, [trace, currentStep, pcToInstruction]); + // Extract variables from the effective (postcondition) context. + const currentVariables = useMemo(() => { + if (!effectiveContext) return []; + return extractVariables(effectiveContext); + }, [effectiveContext]); // Adapt the bugc instruction map + evm trace to the shared // programs-react call-stack helpers, which read the @@ -222,11 +234,14 @@ function TraceDrawerContent(): JSX.Element { return formatPcToInstruction.get(step.pc); }, [trace, currentStep, formatPcToInstruction]); - // Extract call info from current instruction context + // Extract call info from the effective (postcondition) context. const currentCallInfo = useMemo(() => { - if (!currentInstruction) return undefined; - return extractCallInfoFromInstruction(currentInstruction); - }, [currentInstruction]); + if (!effectiveContext) return undefined; + return extractCallInfoFromInstruction({ + offset: 0, + context: effectiveContext, + } as unknown as Program.Instruction); + }, [effectiveContext]); // Build the ethdebug/format instruction object for the current step const currentFormatInstruction = useMemo(() => { @@ -246,7 +261,10 @@ function TraceDrawerContent(): JSX.Element { return extractTransformFromInstruction(currentInstruction); }, [currentInstruction]); - // Build call stack via the shared, tailcall-aware helper. + // Build the call stack via the shared, tailcall-aware helper, on + // the same postcondition timing as the panels above (no + // program-level context from bugc): a frame appears on the step + // the call-info banner first names its invoke. const callStack = useMemo( () => buildCallStack(programsTrace, formatPcToInstruction, currentStep), [programsTrace, formatPcToInstruction, currentStep], @@ -285,7 +303,9 @@ function TraceDrawerContent(): JSX.Element { }); }, [trace, formatPcToInstruction]); - // Resolve argument values for call stack frames + // Resolve argument values for call stack frames. A frame's + // stepIndex is the step whose observed state its argument + // pointers describe (the callee entry's postcondition). const argCacheRef = useRef>(new Map()); const [resolvedArgs, setResolvedArgs] = useState>(