Skip to content

[BUG] Background thread can break test teardown #182

@mawbid

Description

@mawbid

In one of my test suites using pytest-recording, I sometimes get one of two exceptions in vcr.patch.ConnectionRemover.__exit__():

>       for pool, connections in self._connection_pool_to_connections.items():
                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       RuntimeError: dictionary changed size during iteration

or

>           for connection in connections:
                              ^^^^^^^^^^^
E           RuntimeError: Set changed size during iteration

More complete pytest output for one of them is included below.

This code clearly works for a lot of pytest-recording users, so there's got to be something special about the tests or the code under test in my case. I think this is what it is:

Some of the code exercised by the tests pushes work items onto a queue. A background thread picks them up and processes them, and that involves making HTTP requests.

If I call .join() on the queue right after pushing the work item, the problem goes away. I imagine that without the join, some connection handling done in the background thread can end up running concurrently with the ConnectionRemover.__exit__() call, causing the error.

Versions:

vcrpy                        7.0.0
pytest                       8.4.2
pytest-recording             0.13.4
pytest-xdist                 3.8.0
python                       3.13.7

Redacted pytest output:

================================================================ ERRORS =================================================================
___________________________________________ ERROR at teardown of test_XXX ___________________________________________
[gw3] darwin -- Python 3.13.7 /XXX/.venv/bin/python

request = <SubRequest 'vcr' for <Function XXX>>, vcr_markers = [Mark(name='vcr', args=(), kwargs={})]
vcr_cassette_dir = '/XXX/XXX'
record_mode = 'new_episodes', disable_recording = False, pytestconfig = <_pytest.config.Config object at 0x10579d6a0>

    @pytest.fixture(autouse=True)  # type: ignore
    def vcr(
        request: SubRequest,
        vcr_markers: List[Mark],
        vcr_cassette_dir: str,
        record_mode: str,
        disable_recording: bool,
        pytestconfig: Config,
    ) -> Iterator[Optional["Cassette"]]:
        """Install a cassette if a test is marked with `pytest.mark.vcr`."""
        if disable_recording:
            yield None
        elif vcr_markers:
            from ._vcr import use_cassette

            config = request.getfixturevalue("vcr_config")
            default_cassette = request.getfixturevalue("default_cassette_name")
>           with use_cassette(
                default_cassette,
                vcr_cassette_dir,
                record_mode,
                vcr_markers,
                config,
                pytestconfig,
            ) as cassette:

.venv/lib/python3.13/site-packages/pytest_recording/plugin.py:158:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
.venv/lib/python3.13/site-packages/vcr/cassette.py:98: in __exit__
    next(self.__finish, None)
.venv/lib/python3.13/site-packages/vcr/cassette.py:57: in _patch_generator
    with contextlib.ExitStack() as exit_stack:
         ^^^^^^^^^^^^^^^^^^^^^^
../../.local/share/uv/python/cpython-3.13.7-macos-aarch64-none/lib/python3.13/contextlib.py:619: in __exit__
    raise exc
../../.local/share/uv/python/cpython-3.13.7-macos-aarch64-none/lib/python3.13/contextlib.py:604: in __exit__
    if cb(*exc_details):
       ^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <vcr.patch.ConnectionRemover object at 0x11019d130>, args = (None, None, None)
pool = <urllib3.connectionpool.HTTPConnectionPool object at 0x110414c80>
connections = {<vcr.patch.VCRRequestsHTTPConnection/XXX/XXX.yaml object at 0x10fb1e350>, <vcr.patch.VCRRequestsHTTPConnection/XXX/XXX.yaml object at 0x10fa902f0>}
readd_connections = [None, None, None, None, None, None, ...]

    def __exit__(self, *args):
>       for pool, connections in self._connection_pool_to_connections.items():
                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       RuntimeError: dictionary changed size during iteration

.venv/lib/python3.13/site-packages/vcr/patch.py:380: RuntimeError
--------------------------------------------------------- Captured stderr call ----------------------------------------------------------
XXX
----------------------------------------------------------- Captured log call -----------------------------------------------------------
XXX
======================================================== short test summary info ========================================================
ERROR XXX - RuntimeError: dictionary changed size during iteration
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! xdist.dsession.Interrupted: stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
========================================== 187 passed, 2 skipped, 1 xfailed, 1 error in 11.92s ==========================================

(This pytest run was with pytest -n5 using pytest-xdist. I later discovered that concurrent test execution is not required to trigger the exception.)

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions