From 8aeb4e3640ccd0da5ff1498e81ab0355d89735e2 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Tue, 28 Apr 2026 18:34:53 +0700 Subject: [PATCH] fix(test-client-clis): write empty trace placeholder file for rejected txs Extends #2709. When a tx produces a receipt but no TransactionEnd tracer event fires (e.g. EIP-3607 collisions), the in-memory collect_traces appends an empty TransactionTraces, but no matching file was written to --evm-dump-dir. A later --verify-traces run loaded a different shape from disk and reported spurious transaction-count mismatches for the entire class of failed-during- execution tests. Write a zero-byte placeholder when debug_output_path is set, and make TransactionTraces.from_file tolerant of empty files so the loader produces a matching empty TransactionTraces. Fixes #2758. --- .../filler/tests/test_verify_traces.py | 23 +++++ .../client_clis/cli_types.py | 5 ++ .../client_clis/tests/test_transition_tool.py | 85 ++++++++++++++++++- .../client_clis/transition_tool.py | 9 +- 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_verify_traces.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_verify_traces.py index d9a5e4bab03..221442c6672 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_verify_traces.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/tests/test_verify_traces.py @@ -103,6 +103,29 @@ def test_numeric_sorting_not_lexical(self, tmp_path: Path) -> None: # Verify they are in order 0, 2, 10 by checking the list # length — ordering is guaranteed by the implementation + def test_empty_placeholder_trace_file(self, tmp_path: Path) -> None: + """ + Empty trace-*.jsonl placeholders load as empty TransactionTraces. + + TransitionTool.collect_traces writes a zero-byte file when a tx + produced a receipt but no TransactionEnd tracer event fired + (e.g. EIP-3607 collision). The disk-loaded shape must match the + in-memory shape so --verify-traces does not report a spurious + transaction-count mismatch. + """ + call_dir = tmp_path / "0" + call_dir.mkdir() + # One real trace, one empty placeholder. + _write_trace_file(call_dir / "trace-0-0xaaa.jsonl") + (call_dir / "trace-1-0xbbb.jsonl").write_text("") + + result = _load_traces_from_dump_dir(tmp_path) + assert len(result) == 1 + assert len(result[0].root) == 2 + assert len(result[0].root[1].traces) == 0 + assert result[0].root[1].output is None + assert result[0].root[1].gas_used is None + def _make_trace_verifier( json_formatter: Any = None, diff --git a/packages/testing/src/execution_testing/client_clis/cli_types.py b/packages/testing/src/execution_testing/client_clis/cli_types.py index 41b74c73198..c685dd45d72 100644 --- a/packages/testing/src/execution_testing/client_clis/cli_types.py +++ b/packages/testing/src/execution_testing/client_clis/cli_types.py @@ -157,6 +157,11 @@ class TransactionTraces(CamelModel): def from_file(cls, trace_file_path: Path) -> Self: """Read a single transaction's traces from a .jsonl file.""" trace_lines = trace_file_path.read_text().splitlines() + if not trace_lines: + # Empty placeholder written for txs whose tracer's + # TransactionEnd event never fired (see + # TransitionTool.collect_traces). + return cls(traces=[]) trace_dict: Dict[str, Any] = {} if "gasUsed" in trace_lines[-1] and "output" in trace_lines[-1]: trace_dict |= json.loads(trace_lines.pop()) diff --git a/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py b/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py index 5a77e809122..8e1412b95cc 100644 --- a/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py @@ -2,8 +2,9 @@ import shutil import subprocess +import tempfile from pathlib import Path -from typing import Any, Type +from typing import Any, Type, cast import pytest @@ -19,6 +20,7 @@ LazyAlloc, LazyAllocJson, LazyAllocStr, + TransactionReceipt, ) from execution_testing.test_types import Alloc @@ -128,3 +130,84 @@ def test_lazy_alloc(ty: Type[LazyAlloc], raw: Any) -> None: lazy_instance = ty(raw=raw, _state_root=TEST_ALLOC_STATE_ROOT) assert lazy_instance.get() == TEST_ALLOC assert lazy_instance.state_root() == TEST_ALLOC_STATE_ROOT + + +class _CollectTracesSelf: + """ + Minimal stand-in for a ``TransitionTool`` instance. + + ``TransitionTool.collect_traces`` only touches ``self.traces`` and + ``self.append_traces``. Instantiating a real subclass requires an + exception_mapper, binary discovery, etc. — all unrelated to what + we're testing here. + """ + + def __init__(self) -> None: + self.traces: list = [] + + def append_traces(self, new_traces: Any) -> None: + self.traces.append(new_traces) + + +def _make_receipt(tx_hash: str) -> TransactionReceipt: + return TransactionReceipt.model_validate({"transactionHash": tx_hash}) + + +def test_collect_traces_writes_empty_placeholder_for_missing_trace( + tmp_path: Path, +) -> None: + """ + Regression for issue #2758. + + When a tx produces a receipt but no trace file (TransactionEnd + tracer event never fired, e.g. EIP-3607 collisions), an empty + placeholder file must be written into ``debug_output_path`` so a + later ``--verify-traces`` run loads a matching shape from disk. + """ + tx_hash_present = "0x" + "a" * 64 + tx_hash_missing = "0x" + "b" * 64 + + temp_dir = tempfile.TemporaryDirectory() + # Only write a trace for the first receipt; the second is missing. + (Path(temp_dir.name) / f"trace-0-{tx_hash_present}.jsonl").write_text( + '{"output":"0x","gasUsed":"0x5208"}\n' + ) + + debug_dir = tmp_path / "dump" + debug_dir.mkdir() + + receipts = [_make_receipt(tx_hash_present), _make_receipt(tx_hash_missing)] + + self_obj = _CollectTracesSelf() + TransitionTool.collect_traces( + cast(TransitionTool, self_obj), + receipts, + temp_dir, + debug_output_path=debug_dir, + ) + + placeholder = debug_dir / f"trace-1-{tx_hash_missing}.jsonl" + copied = debug_dir / f"trace-0-{tx_hash_present}.jsonl" + assert placeholder.exists(), "missing-trace placeholder not written" + assert placeholder.read_text() == "" + assert copied.exists(), "present trace was not copied to dump dir" + + +def test_collect_traces_no_placeholder_without_debug_path() -> None: + """ + With ``debug_output_path=None`` the missing-trace branch must still + produce an in-memory empty ``TransactionTraces`` but must not write + to disk. + """ + tx_hash = "0x" + "c" * 64 + temp_dir = tempfile.TemporaryDirectory() + self_obj = _CollectTracesSelf() + TransitionTool.collect_traces( + cast(TransitionTool, self_obj), + [_make_receipt(tx_hash)], + temp_dir, + debug_output_path=None, + ) + assert len(self_obj.traces) == 1 + assert len(self_obj.traces[0].root) == 1 + assert self_obj.traces[0].root[0].traces == [] diff --git a/packages/testing/src/execution_testing/client_clis/transition_tool.py b/packages/testing/src/execution_testing/client_clis/transition_tool.py index aad9f89a921..d2528c7f315 100644 --- a/packages/testing/src/execution_testing/client_clis/transition_tool.py +++ b/packages/testing/src/execution_testing/client_clis/transition_tool.py @@ -270,8 +270,15 @@ def collect_traces( # Transaction was rejected mid-processing (e.g. EIP-3607 # collision): the receipt exists but the tracer's # TransactionEnd event never fired, so no trace file was - # written. Record an empty trace for this tx. + # written. Record an empty trace for this tx, and also + # emit an empty placeholder file in the dump dir so a + # later --verify-traces run loads a matching shape from + # disk (otherwise the on-disk loader would skip this + # slot and the comparator would report a spurious + # transaction-count mismatch). traces.append(TransactionTraces(traces=[])) + if debug_output_path: + (Path(debug_output_path) / trace_file_name).write_text("") continue if debug_output_path: shutil.copy(