Skip to content

Commit 2c1a10d

Browse files
authored
fix(test-fill): improve memory buildup for fill; merge on SIGINT / SIGTERM (#2117)
* fix(test-fill): Merge partial fixtures on ``KeyboardInterrupt`` * feat(test-fill): Merge partial fixtures on ``SIGTERM`` * performance: stream fixture and index writes to disk; prevent memory bloat * fix(test-fill): merge indexes on `SIGINT` / `SIGTERM` * fix: add hotfix for `--verify-fixtures`, has been broken for some time.
1 parent d0fd339 commit 2c1a10d

3 files changed

Lines changed: 289 additions & 134 deletions

File tree

packages/testing/src/execution_testing/cli/pytest_commands/plugins/filler/filler.py

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
and writes the generated fixtures to file.
77
"""
88

9+
import atexit
910
import configparser
1011
import datetime
1112
import json
1213
import os
14+
import signal
1315
import warnings
1416
from dataclasses import dataclass, field
1517
from pathlib import Path
@@ -35,6 +37,8 @@
3537
from execution_testing.client_clis.clis.geth import FixtureConsumerTool
3638
from execution_testing.fixtures import (
3739
BaseFixture,
40+
BlockchainEngineFixture,
41+
BlockchainFixture,
3842
FixtureCollector,
3943
FixtureConsumer,
4044
FixtureFillingPhase,
@@ -43,6 +47,7 @@
4347
PreAllocGroupBuilder,
4448
PreAllocGroupBuilders,
4549
PreAllocGroups,
50+
StateFixture,
4651
TestInfo,
4752
merge_partial_fixture_files,
4853
)
@@ -70,6 +75,47 @@
7075
)
7176
from .fixture_output import FixtureOutput
7277

78+
# Fixture output dir for keyboard interrupt cleanup (set in pytest_configure).
79+
# Used by _merge_on_exit to merge partial JSONL files on Ctrl+C or SIGTERM.
80+
_fixture_output_dir: Path | None = None
81+
_atexit_registered: bool = False
82+
_interrupt_count: int = 0
83+
_original_sigint_handler: Any = None
84+
_original_sigterm_handler: Any = None
85+
86+
87+
def _termination_handler(signum: int, frame: Any) -> None:
88+
"""Handle SIGINT/SIGTERM gracefully during test filling."""
89+
del frame
90+
global _interrupt_count
91+
global _original_sigint_handler, _original_sigterm_handler
92+
_interrupt_count += 1
93+
94+
if _interrupt_count == 1:
95+
# First interrupt: restore original handlers and re-raise
96+
if _original_sigint_handler is not None:
97+
signal.signal(signal.SIGINT, _original_sigint_handler)
98+
if _original_sigterm_handler is not None:
99+
signal.signal(signal.SIGTERM, _original_sigterm_handler)
100+
if signum == signal.SIGTERM:
101+
raise SystemExit(128 + signum)
102+
raise KeyboardInterrupt
103+
# Subsequent interrupts: ignore and print message
104+
print("\nMerging fixtures, please wait...", flush=True)
105+
106+
107+
def _merge_on_exit() -> None:
108+
"""Atexit handler to merge partial JSONL files. Ignores signals."""
109+
global _fixture_output_dir
110+
if _fixture_output_dir is not None:
111+
signal.signal(signal.SIGINT, signal.SIG_IGN)
112+
signal.signal(signal.SIGTERM, signal.SIG_IGN)
113+
merge_partial_fixture_files(_fixture_output_dir)
114+
# Also merge index if partial indexes exist
115+
meta_dir = _fixture_output_dir / ".meta"
116+
if meta_dir.exists() and any(meta_dir.glob("partial_index*.jsonl")):
117+
merge_partial_indexes(_fixture_output_dir, quiet_mode=True)
118+
73119

74120
@dataclass(kw_only=True)
75121
class PhaseManager:
@@ -706,6 +752,22 @@ def pytest_configure(config: pytest.Config) -> None:
706752
except ValueError as e:
707753
pytest.exit(str(e), returncode=pytest.ExitCode.USAGE_ERROR)
708754

755+
# Register atexit/signal handlers for cleanup (master only, not workers).
756+
global _fixture_output_dir, _atexit_registered
757+
global _original_sigint_handler, _original_sigterm_handler
758+
is_xdist_worker = hasattr(config, "workerinput")
759+
if not config.fixture_output.is_stdout: # type: ignore[attr-defined]
760+
_fixture_output_dir = config.fixture_output.directory # type: ignore[attr-defined]
761+
if not _atexit_registered and not is_xdist_worker:
762+
atexit.register(_merge_on_exit)
763+
_original_sigint_handler = signal.signal(
764+
signal.SIGINT, _termination_handler
765+
)
766+
_original_sigterm_handler = signal.signal(
767+
signal.SIGTERM, _termination_handler
768+
)
769+
_atexit_registered = True
770+
709771
if (
710772
not config.getoption("disable_html")
711773
and config.getoption("htmlpath") is None
@@ -1047,7 +1109,11 @@ def evm_fixture_verification(
10471109
verify_fixtures_bin = evm_bin
10481110
reused_evm_bin = True
10491111
if not verify_fixtures_bin:
1050-
return
1112+
pytest.exit(
1113+
"--verify-fixtures requires --evm-bin or --verify-fixtures-bin "
1114+
"to be specified.",
1115+
returncode=pytest.ExitCode.USAGE_ERROR,
1116+
)
10511117
try:
10521118
evm_fixture_verification = FixtureConsumerTool.from_binary_path(
10531119
binary_path=Path(verify_fixtures_bin),
@@ -1241,13 +1307,16 @@ def fixture_collector(
12411307
generate_index=request.config.getoption("generate_index"),
12421308
)
12431309
yield fixture_collector
1244-
worker_id = os.environ.get("PYTEST_XDIST_WORKER", None)
1245-
fixture_collector.dump_fixtures(worker_id)
1246-
if do_fixture_verification:
1247-
fixture_collector.verify_fixture_files(evm_fixture_verification)
1248-
# Write partial index for this worker/scope
1249-
if fixture_collector.generate_index:
1250-
fixture_collector.write_partial_index(worker_id)
1310+
try:
1311+
# dump_fixtures() only needed for stdout mode
1312+
fixture_collector.dump_fixtures()
1313+
# Verify fixtures for stdout mode only (files are in memory).
1314+
# For file mode, verification happens at session finish after merge.
1315+
if do_fixture_verification and fixture_output.is_stdout:
1316+
fixture_collector.verify_fixture_files(evm_fixture_verification)
1317+
finally:
1318+
# Always close streaming file handles, even on error
1319+
fixture_collector.close_streaming_files()
12511320

12521321

12531322
@pytest.fixture(autouse=True, scope="session")
@@ -1609,6 +1678,65 @@ def pytest_collection_modifyitems(
16091678
items[:] = slow_items + normal_items
16101679

16111680

1681+
def _verify_fixtures_post_merge(
1682+
config: pytest.Config, output_dir: Path
1683+
) -> None:
1684+
"""
1685+
Verify fixtures after merge if verification is enabled.
1686+
1687+
Called from pytest_sessionfinish after partial files are merged into
1688+
final JSON fixtures. Runs evm statetest/blocktest on each fixture.
1689+
"""
1690+
if not config.getoption("verify_fixtures"):
1691+
return
1692+
1693+
# Get the verification binary (same logic as evm_fixture_verification)
1694+
verify_fixtures_bin = config.getoption("verify_fixtures_bin")
1695+
if not verify_fixtures_bin:
1696+
verify_fixtures_bin = config.getoption("evm_bin")
1697+
if not verify_fixtures_bin:
1698+
return
1699+
1700+
try:
1701+
evm_verification = FixtureConsumerTool.from_binary_path(
1702+
binary_path=Path(verify_fixtures_bin),
1703+
trace=getattr(config, "collect_traces", False),
1704+
)
1705+
except Exception:
1706+
# Binary not recognized, skip verification (error already shown
1707+
# during fixture setup if --verify-fixtures was used)
1708+
return
1709+
1710+
# Map directory names to fixture format classes
1711+
dir_to_format: dict[str, type[BaseFixture]] = {
1712+
StateFixture.output_base_dir_name(): StateFixture,
1713+
BlockchainFixture.output_base_dir_name(): BlockchainFixture,
1714+
BlockchainEngineFixture.output_base_dir_name(): (
1715+
BlockchainEngineFixture
1716+
),
1717+
}
1718+
1719+
# Find all JSON fixture files and verify them
1720+
for json_file in output_dir.rglob("*.json"):
1721+
# Determine fixture format from top-level directory
1722+
relative_path = json_file.relative_to(output_dir)
1723+
if not relative_path.parts:
1724+
continue
1725+
1726+
top_dir = relative_path.parts[0]
1727+
fixture_format = dir_to_format.get(top_dir)
1728+
if fixture_format is None:
1729+
continue
1730+
1731+
if evm_verification.can_consume(fixture_format):
1732+
evm_verification.consume_fixture(
1733+
fixture_format,
1734+
json_file,
1735+
fixture_name=None,
1736+
debug_output_path=None,
1737+
)
1738+
1739+
16121740
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
16131741
"""
16141742
Perform session finish tasks.
@@ -1656,6 +1784,9 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
16561784
for file in fixture_output.directory.rglob("*.lock"):
16571785
file.unlink()
16581786

1787+
# Verify fixtures after merge if verification is enabled
1788+
_verify_fixtures_post_merge(session.config, fixture_output.directory)
1789+
16591790
# Generate index file for all produced fixtures by merging partial indexes.
16601791
# Only merge if partial indexes were actually written (i.e., tests produced
16611792
# fixtures). When no tests are filled (e.g., all skipped), no partial

0 commit comments

Comments
 (0)