Skip to content

A force_reset() window unwound by the garbage collector leaves a live cassette half-patched, and requests escape #1051

Description

@yeion7

Environment

vcrpy 8.3.0, Python 3.13, urllib3 2.x, requests. Single-threaded — no ThreadPoolExecutor anywhere, so this isn't #212 or #849.

What's going on

force_reset() is a @contextmanager, so the window is held open by a suspended generator. If the frame that opened it disappears while that generator is still suspended, nothing unwinds it right away — the interpreter closes it whenever the collector gets around to it. By then some other cassette is usually running, and the ExitStack in force_reset (vcr/patch.py:711) unwinds inside it.

When it does, mock puts back the value it saved when the window opened, which is the real ConnectionCls. The cassette that's currently active had replaced that value, so it loses its patch, and the restore it saved for its own exit is stale too.

reset_patchers() (vcr/patch.py:569) doesn't touch _get_conn, so that one stays wrapped. What you end up with is a cassette that's patched on one side and unpatched on the other:

_get_conn=vcr  ConnectionCls=real

Nothing corrects this, and it lasts for the rest of the cassette. _patched_get_conn only checks that the connection is an instance of pool.ConnectionCls, and a plain HTTPSConnection now passes that check, so every request in the cassette goes out over a real socket. A write-protected cassette stops raising CannotOverwriteExistingCassetteException entirely.

Reproduction

The gc.disable() and the reference cycle below are only there to make the timing deterministic — they aren't the cause. vcrpy opens these windows on its own in VCRConnection.__init__, connect and getresponse (vcr/stubs/__init__.py:292,335,359).

import gc

import requests
import urllib3.connectionpool as cpool
import vcr
import vcr.patch
import vcr.stubs


def state():
    pool = cpool.HTTPSConnectionPool
    get_conn = "vcr" if hasattr(pool._get_conn, "__wrapped__") else "real"
    conn_cls = "vcr" if issubclass(pool.ConnectionCls, vcr.stubs.VCRConnection) else "real"
    return f"_get_conn={get_conn} ConnectionCls={conn_cls}"


# A reset window whose opener is gone. Only the cyclic collector can free it.
gc.disable()
cycle = {"window": vcr.patch.force_reset()}
cycle["self"] = cycle
cycle["window"].__enter__()
del cycle

with vcr.use_cassette("/tmp/empty.yaml", record_mode="none"):
    print("cassette entered :", state())
    gc.enable()
    gc.collect()
    print("after collection :", state())

    # An empty, write-protected cassette should raise
    # CannotOverwriteExistingCassetteException here.
    try:
        requests.get("https://127.0.0.1:1/", timeout=1)
    except Exception as exc:
        print("request raised   :", type(exc).__name__)

Output:

cassette entered : _get_conn=vcr ConnectionCls=vcr
after collection : _get_conn=vcr ConnectionCls=real
request raised   : ConnectionError

That ConnectionError is the tell. The request left the process instead of being played back from the cassette.

How it shows up for us

As a rare CI flake, roughly one job in several hundred. pytest-socket blocks the escaped connection and the test fails with a host it never asked for. The test that fails isn't the one that opened the window, so the traceback points somewhere unhelpful and there's nothing in it to suggest vcrpy is involved.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions