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:
Fork at a fixed, shared block, never ``latest``. Use your chain’s canonical
*_MIDNIGHT_BLOCKconstant frometh_defi.testing.fork_blocks(viaget_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.Obtain Web3 from the shared pool, not from a private
fork_network_anvilcall, so every same-chain, same-block test reuses one Anvil process. Have the module’sweb3fixture callAnvilForkPool.get_web3()with the session-scopedanvil_fork_poolfixture.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
WedgedForkRecycledWarningis 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 toeth_defi.provider.anvil.fork_network_anvil()and relies onlaunch_anvil’s defaultproxy_multiple_upstream=True(the bounded automatic failover proxy). When aJSON_RPC_*value carries multiple space-separated providers,launch_anvilbuildseth_defi.provider.anvil._create_default_anvil_proxy_config(), which setsretries = provider_countandbackoff = 0.0— the proxy tries each upstream exactly once, fails over instead of retrying a dead endpoint, and keeps its whole pass underANVIL_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 clientReadTimeout. 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.pyandtests/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
web3fixture calls the pool explicitly. Anautouse=Truerestore fixture in a sharedconftest.pycausesScopeMismatchwhen sibling modules overrideweb3at function scope.Pin sharers to one xdist worker.
--dist loadgroup(used in CI) sends all tests marked with the samexdist_groupto 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 configurablelaunch_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
Do not retry a wedged local Anvil after its proxy has tried every upstream. |
|
Preserve the established connect/read timeout for legitimate cold-cache calls. |
|
Seconds allowed for the liveness probe on a reused pooled fork. |
Functions
|
Check that a pooled Anvil fork still answers JSON-RPC promptly. |
Classes
Registry of shared Anvil forks keyed by launch configuration. |
Exceptions
Warn that a fork RPC session has only one upstream provider. |
|
Warn that an unresponsive pooled Anvil fork was disposed and relaunched. |
- exception SingleRpcProviderWarning
Bases:
UserWarningWarn 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 — seedocs/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_chainIdfrom 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 belowPOOL_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:
UserWarningWarn that an unresponsive pooled Anvil fork was disposed and relaunched.
A long-lived shared fork can stop answering under sustained load (see the
AnvilSnapshotStatedocstring ineth_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 sread_timeoutagainstlocalhost.This is a
UserWarningon 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:
``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.Raw ``requests`` instead of Web3. Going through
create_multi_provider_web3()would inheritPOOL_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 atPOOL_LIVENESS_TIMEOUT.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
launch (eth_defi.provider.anvil.AnvilLaunch) – Pooled Anvil launch to probe. Only its
json_rpc_urlis used, so this is cheap to call and safe on a process that may already be dead.timeout (float) – Seconds to wait for the reply before declaring the fork wedged. Defaults to
POOL_LIVENESS_TIMEOUT.
- Returns
Truewhen Anvil replied with a well-formed JSON-RPC result within the timeout,Falseotherwise (caller should dispose and relaunch).- Return type
- class AnvilForkPool
Bases:
objectRegistry of shared Anvil forks keyed by launch configuration.
One
AnvilLaunchis created per distinct launch configuration and reused for every caller (on the same xdist worker) that requests it. Callclose_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_anvilarguments; they are part of the cache key so incompatible configs never share a process. Whenproxy_multiple_upstreamis omitted,launch_anvil’s default applies (True— the bounded, fail-fast automatic failover proxy; see the module docstring); pass an explicitRPCProxyConfig,RPCProxy, orFalseto override.
- Returns
The shared
AnvilLaunch.- Return type
- 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 returnedweb3.Web3object 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_anvilarguments (part of the cache key).
- Returns
A
web3.Web3connected 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