Repository navigation
[regression][8.2.2] KeyError sometimes crashes test collection on PyPy while reordering fixtures #13312
Description
So @bdraco was adding new tests in aio-libs/multidict#1072. And it worked at first. But they had readability problems, and I added a few refactoring commits on top. And that's when things went south.
The first version of the tests made use of @pytest.mark.parametrize which I later converted into a parametrized fixture. That specific fixture depends on request: pytest.FixtureRequest, but doesn't depend on anything else we have declared in conftest.py.
The params set uses a trick that @The-Compiler has shown in his EuroPython 2023 workshop — wrapping them in dataclasses featuring __str__() interface. Not sure if that's relevant, though.
So the traceback I'm talking about is only happening on PyPy and looks like this:
Run python -Im pytest tests -v --cov-report xml --junitxml=.test-results/pytest/test.xml --no-c-extensions
============================= test session starts ==============================
platform linux -- Python 3.9.19[pypy-7.3.16-final], pytest-8.3.5, pluggy-1.5.0 -- /opt/hostedtoolcache/PyPy/3.9.19/x64/bin/python
codspeed: 3.2.0 (disabled, mode: walltime, timer_resolution: 1.0ns)
cachedir: .pytest_cache
rootdir: /home/runner/work/multidict/multidict
configfile: pytest.ini
plugins: cov-6.0.0, codspeed-3.2.0
collecting ... collected 1161 items
INTERNALERROR> Traceback (most recent call last):
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/main.py", line 283, in wrap_session
INTERNALERROR> session.exitstatus = doit(config, session) or 0
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/main.py", line 336, in _main
INTERNALERROR> config.hook.pytest_collection(session=session)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_hooks.py", line 513, in __call__
INTERNALERROR> return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_manager.py", line 120, in _hookexec
INTERNALERROR> return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 139, in _multicall
INTERNALERROR> raise exception.with_traceback(exception.__traceback__)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 122, in _multicall
INTERNALERROR> teardown.throw(exception) # type: ignore[union-attr]
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/logging.py", line 790, in pytest_collection
INTERNALERROR> return (yield)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 122, in _multicall
INTERNALERROR> teardown.throw(exception) # type: ignore[union-attr]
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/warnings.py", line 121, in pytest_collection
INTERNALERROR> return (yield)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 122, in _multicall
INTERNALERROR> teardown.throw(exception) # type: ignore[union-attr]
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/config/__init__.py", line 1417, in pytest_collection
INTERNALERROR> return (yield)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 103, in _multicall
INTERNALERROR> res = hook_impl.function(*args)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/main.py", line 347, in pytest_collection
INTERNALERROR> session.perform_collect()
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/main.py", line 812, in perform_collect
INTERNALERROR> hook.pytest_collection_modifyitems(
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_hooks.py", line 513, in __call__
INTERNALERROR> return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_manager.py", line 120, in _hookexec
INTERNALERROR> return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 139, in _multicall
INTERNALERROR> raise exception.with_traceback(exception.__traceback__)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 122, in _multicall
INTERNALERROR> teardown.throw(exception) # type: ignore[union-attr]
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/cacheprovider.py", line 443, in pytest_collection_modifyitems
INTERNALERROR> res = yield
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 122, in _multicall
INTERNALERROR> teardown.throw(exception) # type: ignore[union-attr]
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/cacheprovider.py", line 373, in pytest_collection_modifyitems
INTERNALERROR> res = yield
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/pluggy/_callers.py", line 103, in _multicall
INTERNALERROR> res = hook_impl.function(*args)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/fixtures.py", line 1627, in pytest_collection_modifyitems
INTERNALERROR> items[:] = reorder_items(items)
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/fixtures.py", line 233, in reorder_items
INTERNALERROR> reorder_items_atscope(
INTERNALERROR> File "/opt/hostedtoolcache/PyPy/3.9.19/x64/lib/pypy3.9/site-packages/_pytest/fixtures.py", line 282, in reorder_items_atscope
INTERNALERROR> other_scoped_items_by_argkey[argkey].move_to_end(
INTERNALERROR> KeyError: <Function test_setdefault[case-insensitive-pure-python-module]>
============================ no tests ran in 5.81s =============================
Error: Process completed with exit code 3.I was able to reproduce it locally on my Gentoo Linux laptop using pypy3.9-7.3.16. But I wasn't really able to make the repro smaller. I learned that this traceback is first seen in pytest == 8.2.2. And pytest == 8.2.1 does not have this problem.
So the current STR is something like:
pyenv install pypy3.9-7.3.16pyenv virtualenv pypy3.9-7.3.16 multidict-pyenv-pypy3.9-7.3.16pyenv shell multidict-pyenv-pypy3.9-7.3.16- Get a copy of Add coverage for making calls with incorrect arguments aio-libs/multidict#1072
pip install -r requirements/pytest.txtpip install -e .SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST='8.2.2.dev0+first-broken-due-to-pr12414-regression' pip install 'pytest @ https://github.com/pytest-dev/pytest/archive/214d098fcce88940f5ce9353786b3cc8f0bd3938.tar.gz'- And finally run
pytest --collect-only --no-cov tests/test_abc.py tests/test_copy.py tests/test_incorrect_args.py tests/test_multidict.py tests/test_mypy.py tests/test_pickle.py tests/test_types.py tests/test_update.py tests/test_version.py
These aren't all the test modules of the project. Removing them from CLI args or removing some of the tests inside makes it so that it doesn't traceback anymore. That's why I wasn't able to come up with a single-snippet reproducer. It feels like it only happens when a certain amount of tests is hit or something.
I also ran it with --pdb which allowed me to see the value of item at that point when OrderedDict.move_to_end() is called: https://github.com/pytest-dev/pytest/blame/2b40981/src/_pytest/fixtures.py#L278C29-L280C30.
It kinda suggests that the PyPy implementation of OrderedDict is weird:
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
> ~/.pyenv/versions/pypy3.9-7.3.16/envs/multidict-pyenv-pypy3.9-7.3.16/lib/pypy3.9/site-packages/_pytest/fixtures.py(236)fix_cache_order()
-> scoped_items_by_argkey[key].move_to_end(item, last=False)
(Pdb) pp item
<Function test_popitem[case-insensitive-pure-python-module]>
(Pdb) pp [(k, v) for k, v in scoped_items_by_argkey[key].items() if str(k) == '<Function test_popitem[case-insensitive-pure-python-module]>']
[(<Function test_popitem[case-insensitive-pure-python-module]>, None),
(<Function test_popitem[case-insensitive-pure-python-module]>, None)]
(Pdb) pp scoped_items_by_argkey[key][item]
*** KeyError: <Function test_popitem[case-insensitive-pure-python-module]>
(Pdb) pp [(k, v, id(k), hash(k)) for k, v in scoped_items_by_argkey[key].items() if str(k) == '<Function test_popitem[case-insensitive-pure-python-module]>']
[(<Function test_popitem[case-insensitive-pure-python-module]>,
None,
140541205124888,
-760398202047905062),
(<Function test_popitem[case-insensitive-pure-python-module]>,
None,
140541205124888,
-760398202047905062)]
(Pdb) pp id(item), hash(item)
(140541205124888, -760398202047905062)🤔 Why does this dict have duplicate keys? 😱
Anyway.. I confirmed that doing SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST='8.2.2.dev0+last-working-right-before-pr12414-regression' pip install 'pytest @ https://github.com/pytest-dev/pytest/archive/b41d5a52bbb808780ab310456d71e5ce509fd402.tar.gz' makes the problem go away (which is expected because it does not yet have the OrderedDict.move_to_end() call).
And so this makes #12414 (#12409) responsible for the regression.
cc @bluetech I hope you'll have at least some idea of where to look because I've spent enough time staring at this w/o any clue...
UPD: I also forgot to mention that removing our own pytest_collection_modifyitems in conftest.py does not help in any way, it still reproduces. Plus, changing the fixture scopes to match what we have in conftest.py has no effect either.
UPD2: pypy3.11-7.3.19 shows this behavior too.
Activity
🧪 Use Pytest 8.2.1 on PyPy
[-][regression][8.2.2] `KeyError` sometimes crashes test collection on PyPy 3.9 while reordering fixtures[/-][+][regression][8.2.2] `KeyError` sometimes crashes test collection on PyPy while reordering fixtures[/+]RonnyPfannschmidt commentedon Mar 20, 2025
i believe we may need to report this to pypy as well
mgorny commentedon Apr 1, 2025
CC @mattip, @cfbolz
webknjaz commentedon Apr 1, 2025
Oh, I forgot to notify PyPy 🤦♂️. Thanks for tagging them!
RonnyPfannschmidt commentedon Apr 10, 2025
i suspect the key differerenve is somewhere in https://github.com/pypy/pypy/blob/branches/release-pypy3.10-v7.x/pypy/module/_collections/app_odict.py
mgorny commentedon Apr 10, 2025
Unfortunately, debugging RPython is above my pay grade. It would be interesting to find out when duplicate keys get introduced, but at least at a first glance, nothing stands out. Most of the code is calling into
dictsuperclass, andmove_to_end()is literallydel+ set again.5 remaining items
cfbolz commentedon Apr 11, 2025
thanks everyone for trying to track this down! I'll try to understand what's going on and have two questions that would help me a lot:
move_to_endis used, and that that leads to the bug somehow?It's likely that the bug managed to hide for a long time because
move_to_endis a relatively rare operations.RonnyPfannschmidt commentedon Apr 11, 2025
move_to_end is used and the key is a pytest internal type
we use a
OrderedDict[Item, None]as a OrderedSet[Item]webknjaz commentedon Apr 11, 2025
@cfbolz it is used, but I'm not entirely sure that it is the underlying cause of the issue (unless you're implying that previous invocations of this method on the same object had effect at some point in the future). When I was running pdb, I was trying to access the problematic key through normal square brackets and it was giving me the same key error. And then I listed the keys, and noticed that the problematic one was duplicated.
mgorny commentedon May 20, 2025
Okay, I'm not sure if this is something you want to do upstream, but to unblock Gentoo for the time being I'm going to apply the following hack:
i.e. just inline
move_to_end(last=False)(unless I messed the logic up).dev-python/pytest: Workaround the PyPy OrderedDict.move_to_end bug
bluetech commentedon May 25, 2025
Chasing the link to the PyPy code that @RonnyPfannschmidt linked, I think that
move_to_end(last=False)ends up in this code:https://github.com/pypy/pypy/blob/f58a4aee085665550ee92f65930176d8284dae9c/rpython/rtyper/lltypesystem/rordereddict.py#L1537-L1621
It is too complex for me to figure out quickly. But I do see it contains several assertions (
ll_assertcalls). I'm assuming these assertions are turned off in a production build of PyPy, but if the bug can be reproduced under an assertions-enabled build of PyPy maybe we get lucky and one the assertions will fail.If I get some time, I will also try to run some property tests for OrderedDict.move_to_end on PyPy, maybe we get lucky with this and find a bug...
I am also trying to read
reorder_itemsagain to understand if the problem might be on the pytest side, but that code is a bit of a maze (it was much worse in the past!)...cfbolz commentedon May 25, 2025
I found a shorter reproducer, see I the discussion pypy/pypy#5257
I'm fairly close to solving it, basically move_to_first can break an invariant of the dict (which doesn't have an ll_assert 🙄).
bluetech commentedon May 25, 2025
@cfbolz Awesome!
Just for trivia, I did ask an LLM (Claude 4 Sonnet) to generate some Hypothesis tests for
OrderedDict.move_to_end, it produced the beast below, however it doesn't find the bug on PyPy 7.3.19. 👍 for Humanity :)AI-generated Hypothesis tests
bluetech commentedon May 26, 2025
So given there is a PyPy bug (still super surprised by this, this definitely goes on the pytest Wall of Bugs, along with this cpython crasher), maybe we should workaround it?
@mgorny
I'm afraid it is messed up - not doing anything with
new_dict.Using the "new dict and assign" strategy is problematic because the type of
other_scoped_items_by_argkeyisMappingwhich doesn't allow mutation (even though it's actually a dict, type checker will complain). This is a property I'd like to preserve. So I think we want a "backup, clear and update" strategy. That's a bit gruesome but I at least can't think of a more efficient alternative tomove_to_end(last=False). I will send a PR.fixtures: workaround PyPy bug which sometimes causes a `KeyError` cra…
fixtures: workaround PyPy bug which sometimes causes a `KeyError` cra…
fixtures: workaround PyPy bug which sometimes causes a `KeyError` cra…
chore(deps): update dependency pytest to v9 (#305)