testing.anvil_fork_pool

Documentation for eth_defi.testing.anvil_fork_pool Python module.

Session-scoped Anvil fork pool for reusing forks across test modules.

This module docstring is the canonical, authoritative description of the shared Anvil fork + fixed fork-block + warm RPC-cache test pattern. Other places in the repository (CLAUDE.md, the add-vault-protocol skill, eth_defi.testing.fork_blocks, eth_defi/testing/README.md) only point here; do not duplicate the rationale — update this docstring instead.

Reusable testing helper (kept under eth_defi rather than tests per the repository convention) that lets many tests sharing the same (chain, fork_block_number, launch config) reuse a single Anvil process instead of each launching (and archive-replaying) its own. This is Lever 1 of the test-suite performance plan (docs/README-test-suite-performance.md).

The pytest fixture wrapper lives in the top-level tests/conftest.py; this module holds only the reusable pool class.

Required pattern for new Anvil mainnet-fork tests

Any new Anvil mainnet-fork characterisation test must use this pattern rather than launching its own per-file fork at a chain-tip / ad-hoc block:

  1. Fork at a fixed, shared block, never ``latest``. Use your chain’s canonical *_MIDNIGHT_BLOCK constant from eth_defi.testing.fork_blocks (via get_midnight_block() for a programmatic lookup). A mutable chain tip is non-reproducible, cannot be shared, and defeats the RPC cache. A fixed block also lets value assertions use exact numbers (per the repository test rules) rather than fuzzy bounds.

  2. Obtain Web3 from the shared pool, not from a private fork_network_anvil call, so every same-chain, same-block test reuses one Anvil process. Have the module’s web3 fixture call AnvilForkPool.get_web3() with the session-scoped anvil_fork_pool fixture.

  3. Carry the matching ``xdist_group`` marker so --dist loadgroup (used in CI) co-locates all sharers on one worker — session scope is per worker, so without the marker the “shared” fork silently forks once per worker. Use one stable group name per (chain, block), e.g. @pytest.mark.xdist_group("fork:ethereum:midnight").

Why this matters — the warm RPC cache

Anvil persists every eth_call / eth_getStorageAt archive response it replays to ~/.foundry/cache/rpc/<network>/<block>/ — but only on a graceful shutdown (its Drop flushes the file; a SIGKILL discards it). So close() SIGTERM``s Anvil and waits for the flush before falling back to ``SIGKILL; without that the cache is never written and every run cold-fetches. When many characterisation tests fork the same fixed block, they read overlapping state, so that on-disk cache becomes dense and later runs replay from disk instead of re-hammering the upstream archive node. CI restores and re-saves this directory across runs (see the “Foundry fork RPC cache” steps in .github/workflows/test-vault-protocol.yml and docs/README-test-suite-performance.md), so a warm cache turns cold-archive stalls (the ~476 s startup seen on cold Ethereum archive forks) into ~seconds.

Forking latest or a per-test arbitrary block breaks all three benefits: the cache key never repeats, nothing is shared, and every run pays full archive-replay latency. That is why the fixed shared block is mandatory.

Cold-fork read-timeout failures — the CI symptom and what it means

Fork setup can fail with a 60-second eth_chainId read timeout, historically hinted (misleadingly) as “out of API credits”. The classified failure_mode is read_timeout: Anvil is blocked initialising its fork against the upstream archive and does not answer the first call in time.

Measured expected delay (scripts/measure-cold-fork-time.py, anvil 1.7.1, 2026-07-25): a single cold fork of a midnight block completes in ~3 seconds, and even six concurrent cold forks of the same block stay at ~2.5 s each. So the 60 s Web3 read timeout (POOL_WEB3_HTTP_TIMEOUT) is already ~20× a healthy cold fork and is sufficient — a timeout is not the cap being too small, and raising it only delays the failure (do not raise it to mask a slow provider).

A 60 s timeout in CI therefore means the upstream provider is slow or is rate-limiting the runner IP, not that the fork is legitimately slow and not that credits are exhausted (a healthy fork answers in seconds). The fix is upstream reliability, not a bigger timeout: (1) a warm fork RPC cache so CI never cold-fetches (see above and the repo-seed mechanism in eth_defi.testing.rpc_cache), (2) provider failover across space-separated JSON_RPC_* endpoints, (3) a provider that does not throttle the CI IP. Run the measurement script from a machine using the CI RPC secrets to compare its cold-fork times against this ~3 s local baseline.

Where the upstream-stall reason shows up. With multiple providers the fork routes through the automatic failover proxy (eth_defi.provider.anvil._create_default_anvil_proxy_config()), which tries each upstream once and logs every stall/error at WARNING — pytest captures WARNING and above into the failed test’s “Captured log” section, so that is where you see which provider stalled and why (connection timeout, retryable HTTP error). With a single provider there is no proxy and no failover: a SingleRpcProviderWarning is emitted and the only signal is the local eth_chainId read timeout — configure a second provider to get both failover and the diagnosis.

Wedged-fork recycling — one bad fork must not fail its whole group

Background. A long-lived shared fork can stop answering under sustained load. This is the Anvil responsiveness degradation already documented in the AnvilSnapshotState docstring in eth_defi.provider.anvil, and it is the known trade-off of the shared-fork pool introduced in PR #1360 (which replaced per-test forks with long-lived pooled ones).

Why it was so damaging. A pooled fork is shared by every test carrying its xdist_group marker, so one wedged process failed all of them at setup — and each victim paid the full 60 s Web3 read timeout against localhost before erroring. Measured on a full local vault-protocol run (100 % warm RPC cache, single unthrottled providers, so neither cold-fetch nor provider throttling was involved): 8 wedged Anvil processes produced 44 failures/errors, 19 of them from a single fork:ethereum:midnight fork. Note the timeout host is localhost — that is the tell that distinguishes this from the upstream throttling described in the previous section, which was the original subject of PR #1372 and the CI triage in PR #1370.

The fix. AnvilForkPool.get_launch() probes a reused fork with is_fork_alive() before handing it out — a raw eth_chainId bounded by POOL_LIVENESS_TIMEOUT and sent outside the Web3 stack so it cannot inherit the 60 s timeout it exists to avoid. An unresponsive fork is disposed and relaunched, turning “the rest of this group is doomed” into “one fork restarts”.

Deliberate scope limits (read before extending this):

  • Newly launched forks are not probed. fork_network_anvil() already smoke-tests a fresh process, so probing again would only add a round-trip to every cold start.

  • This makes a wedged fork recoverable, not impossible. It does not address why Anvil degrades under sustained pooled load. A burst of WedgedForkRecycledWarning is therefore a signal to investigate (cap tests-per-fork, recycle proactively), which is exactly why the recycling warns loudly instead of silently papering over the problem.

  • Recycling resets EVM state. A relaunched fork is a fresh fork: any post-launch deployment or mutation a test group relied on is gone. That is safe for the read-only characterisation tests this pool currently serves (see the warning at the end of this docstring), but a mutating shared-fork user must re-establish its baseline rather than assume continuity.

Bounded provider retries — fail fast, never re-hammer a dead provider

Forks must fail within a bounded period when an upstream archive provider is exhausted or unavailable, so a genuine outage surfaces quickly instead of stalling until the job timeout. The pool pins this explicitly rather than relying on an upstream default that could regress:

  • Upstream (Anvil → archive) — one attempt per provider, no re-hammer. AnvilForkPool.get_launch() forwards to eth_defi.provider.anvil.fork_network_anvil() and relies on launch_anvil’s default proxy_multiple_upstream=True (the bounded automatic failover proxy). When a JSON_RPC_* value carries multiple space-separated providers, launch_anvil builds eth_defi.provider.anvil._create_default_anvil_proxy_config(), which sets retries = provider_count and backoff = 0.0 — the proxy tries each upstream exactly once, fails over instead of retrying a dead endpoint, and keeps its whole pass under ANVIL_PROXY_TOTAL_TIMEOUT (55 s), i.e. below the 60 s Web3 localhost read timeout, so an all-providers-down failure returns a classified proxy error rather than a client ReadTimeout. A single-provider value starts no proxy (nothing to fail over to — that is why the CI secrets should carry two providers per chain).

  • Local (Web3 → Anvil) — zero retries. POOL_WEB3_RETRIES = 0: the client makes one attempt against local Anvil because the proxy has already performed upstream failover; retrying here would only multiply the wait.

Callers with a legitimately slow fork operation can override the Web3 retry count and HTTP timeout in AnvilForkPool.get_web3(), or pass an explicit proxy_multiple_upstream=RPCProxyConfig(...) / RPCProxy / proxy_multiple_upstream=False through the launch kwargs. Do not shorten the per-attempt proxy timeout below a legitimate cold-archive read — the fail-fast win is fewer attempts, not shorter ones; shorter ones re-introduce flakiness on cold reads.

Reference tests to copy

  • Read-only pooled fork: tests/erc_4626/vault_protocol/test_goat.py and tests/erc_4626/vault_protocol/test_aarna.py.

  • Shared fork plus once-per-session expensive deployment (Safe/Lagoon): tests/lagoon/conftest.py.

Copy-paste module skeleton

import os

import pytest
from web3 import Web3

from eth_defi.testing.anvil_fork_pool import AnvilForkPool
from eth_defi.testing.fork_blocks import ETHEREUM_MIDNIGHT_BLOCK

JSON_RPC_ETHEREUM = os.environ.get("JSON_RPC_ETHEREUM")

pytestmark = [
    pytest.mark.skipif(JSON_RPC_ETHEREUM is None, reason="JSON_RPC_ETHEREUM needed to run these tests"),
    # Co-locate every same-block Ethereum sharer on one xdist worker.
    pytest.mark.xdist_group("fork:ethereum:midnight"),
]


@pytest.fixture(scope="module")
def web3(anvil_fork_pool: AnvilForkPool) -> Web3:
    # Read-only test: shares one Anvil fork from the session pool, so no
    # snapshot/revert reset is needed between tests.
    return anvil_fork_pool.get_web3(JSON_RPC_ETHEREUM, ETHEREUM_MIDNIGHT_BLOCK)

Design notes:

  • Opt-in, never autouse. A test module’s own web3 fixture calls the pool explicitly. An autouse=True restore fixture in a shared conftest.py causes ScopeMismatch when sibling modules override web3 at function scope.

  • Pin sharers to one xdist worker. --dist loadgroup (used in CI) sends all tests marked with the same xdist_group to one worker, and pytest session scope is per worker — so tests sharing a fork must carry an identical @pytest.mark.xdist_group("fork:<chain>:<block>") marker.

  • The registry key is the full launch config, not just (chain, block): eth_defi.provider.anvil.fork_network_anvil() is a thin alias of the fully configurable launch_anvil, so differing hardfork / gas / unlocked-account / tracing options must not collide on one cached process.

Warning

Proof-of-concept, gated on CI. The repository documents that repeated snapshot/revert cycles on a long-lived, module/session-scoped fork can degrade Anvil responsiveness and hang CI under pytest-xdist (see the AnvilSnapshotState docstring in eth_defi.provider.anvil). The initial proof-of-concept only shares forks between read-only tests, which do not mutate fork state and therefore need no snapshot/revert between tests. Mutating tests that share a fork must additionally reset it with eth_defi.testing.evm_snapshot_fixture.evm_snapshot_revert() or eth_defi.provider.anvil.reset_anvil_snapshot(); that path is not yet wired here, pending a bounded CI run that proves no xdist hang.

Module Attributes

POOL_WEB3_RETRIES

Do not retry a wedged local Anvil after its proxy has tried every upstream.

POOL_WEB3_HTTP_TIMEOUT

Preserve the established connect/read timeout for legitimate cold-cache calls.

POOL_LIVENESS_TIMEOUT

Seconds allowed for the liveness probe on a reused pooled fork.

Functions

is_fork_alive(launch[, timeout])

Check that a pooled Anvil fork still answers JSON-RPC promptly.

Classes

AnvilForkPool

Registry of shared Anvil forks keyed by launch configuration.

Exceptions

SingleRpcProviderWarning

Warn that a fork RPC session has only one upstream provider.

WedgedForkRecycledWarning

Warn that an unresponsive pooled Anvil fork was disposed and relaunched.

exception SingleRpcProviderWarning

Bases: UserWarning

Warn that a fork RPC session has only one upstream provider.

With a single upstream there is no failover: an exhausted or rate-limited archive provider fails the fork instead of switching to a second endpoint. Emitted (and shown in the pytest warnings summary + CI logs) so a single-provider JSON_RPC_* configuration is visible as the root cause of flaky fork tests. Configure two space-separated providers per chain to silence it — see docs/README-test-suite-performance.md.

__init__(*args, **kwargs)
__new__(**kwargs)
add_note(note, /)

Add a note to the exception

with_traceback(tb, /)

Set self.__traceback__ to tb and return self.

POOL_WEB3_RETRIES: int = 0

Do not retry a wedged local Anvil after its proxy has tried every upstream.

POOL_WEB3_HTTP_TIMEOUT: tuple[float, float] = (3.0, 60.0)

Preserve the established connect/read timeout for legitimate cold-cache calls.

POOL_LIVENESS_TIMEOUT: float = 5.0

Seconds allowed for the liveness probe on a reused pooled fork.

A healthy Anvil answers eth_chainId from memory in milliseconds, so this budget only has to cover process scheduling and a loaded CI runner — not any real work. It must stay far below POOL_WEB3_HTTP_TIMEOUT (60 s), because the entire point of the probe is to detect a wedged fork in seconds instead of paying that 60 s timeout once per test in the group (see the “Wedged-fork recycling” section of the module docstring — 8 wedged forks cost 44 test failures before this existed). Raising it towards 60 s would defeat the mechanism; lowering it risks recycling a merely busy fork, which is cheap but wasteful.

exception WedgedForkRecycledWarning

Bases: UserWarning

Warn that an unresponsive pooled Anvil fork was disposed and relaunched.

A long-lived shared fork can stop answering under sustained load (see the AnvilSnapshotState docstring in eth_defi.provider.anvil, and the “Wedged-fork recycling” section of this module’s docstring). Without recycling, every remaining test sharing that fork fails at setup with a 60 s read_timeout against localhost.

This is a UserWarning on purpose: recycling repairs the symptom so the suite can continue, but it does not fix the underlying degradation. Surfacing it in the pytest warnings summary keeps a fork that wedges repeatedly visible — treat a burst of these as a signal to cap tests-per-fork or recycle proactively, not as normal background noise.

Introduced in PR #1372.

__init__(*args, **kwargs)
__new__(**kwargs)
add_note(note, /)

Add a note to the exception

with_traceback(tb, /)

Set self.__traceback__ to tb and return self.

is_fork_alive(launch, timeout=5.0)

Check that a pooled Anvil fork still answers JSON-RPC promptly.

Used by AnvilForkPool.get_launch() to decide whether a reused fork is still healthy or must be recycled. See the “Wedged-fork recycling” section of this module’s docstring for the full rationale and the measurements that motivated it.

Three deliberate design choices, each of which matters:

  1. ``eth_chainId`` as the probe call. Anvil answers it from memory without touching the upstream archive, so a slow reply isolates Anvil process unresponsiveness from upstream provider slowness. Probing with a state-reading call (eth_getBalance, eth_call) would conflate the two and could wrongly recycle a healthy fork that is merely waiting on a cold archive read.

  2. Raw ``requests`` instead of Web3. Going through create_multi_provider_web3() would inherit POOL_WEB3_HTTP_TIMEOUT (60 s read) — the very timeout this probe exists to avoid paying once per test in the group. A raw POST lets us bound the check at POOL_LIVENESS_TIMEOUT.

  3. Fail closed. Any failure — timeout, refused connection, HTTP error, or an unparseable body — is treated as “not usable”. Relaunching a fork that was actually fine costs one extra fork (seconds, and the RPC cache is warm); not relaunching a wedged one costs every remaining test in the group a 60 s timeout.

Parameters
Returns

True when Anvil replied with a well-formed JSON-RPC result within the timeout, False otherwise (caller should dispose and relaunch).

Return type

bool

class AnvilForkPool

Bases: object

Registry of shared Anvil forks keyed by launch configuration.

One AnvilLaunch is created per distinct launch configuration and reused for every caller (on the same xdist worker) that requests it. Call close_all() once to tear every launch down.

Intended for read-only fork tests in its current form; see the module warning for the mutating-test caveat.

launches: dict[tuple, eth_defi.provider.anvil.AnvilLaunch]

Cached launches keyed by (rpc_url, fork_block_number, sorted launch kwargs).

get_launch(rpc_url, fork_block_number, **launch_kwargs)

Return a shared Anvil launch for this exact launch configuration.

Launches Anvil lazily on the first request for a configuration and returns the cached process on every subsequent request.

Parameters
  • rpc_url (str) – Upstream archive JSON-RPC URL to fork from.

  • fork_block_number (int) – Fixed block to fork at. Required — a mutable chain tip cannot be shared safely.

  • launch_kwargs (Any) – Any other state-affecting fork_network_anvil arguments; they are part of the cache key so incompatible configs never share a process. When proxy_multiple_upstream is omitted, launch_anvil’s default applies (True — the bounded, fail-fast automatic failover proxy; see the module docstring); pass an explicit RPCProxyConfig, RPCProxy, or False to override.

Returns

The shared AnvilLaunch.

Return type

eth_defi.provider.anvil.AnvilLaunch

get_web3(rpc_url, fork_block_number, *, web3_retries=0, web3_http_timeout=(3.0, 60.0), **launch_kwargs)

Return a fresh Web3 pointed at a shared Anvil fork.

The underlying Anvil process is shared via get_launch(); the returned web3.Web3 object itself is created per call and is not shared.

Parameters
  • rpc_url (str) – Upstream archive JSON-RPC URL to fork from.

  • fork_block_number (int) – Fixed block to fork at.

  • web3_retries (int) – Number of outer retries against the local Anvil endpoint. Keep this low because the inner RPC proxy already performs provider failover.

  • web3_http_timeout (tuple[float, float]) – Connect and read timeout for local Anvil requests. Override this for a known slow cold-cache operation.

  • launch_kwargs (Any) – Additional fork_network_anvil arguments (part of the cache key).

Returns

A web3.Web3 connected to the shared Anvil RPC endpoint.

Return type

web3.main.Web3

close_all()

Tear down every launched Anvil process.

Every launch is attempted even if an earlier one fails to close, so a single teardown error cannot leak the remaining processes.

Return type

None

__init__(launches=<factory>)
Parameters

launches (dict[tuple, eth_defi.provider.anvil.AnvilLaunch]) –

Return type

None