Skip to content

Fix the itertools.tee leak, the contextvars and generator races, and _imp's frozen data - #8518

Draft
youknowone wants to merge 5 commits into
RustPython:mainfrom
youknowone:fuzzer-issues
Draft

Fix the itertools.tee leak, the contextvars and generator races, and _imp's frozen data#8518
youknowone wants to merge 5 commits into
RustPython:mainfrom
youknowone:fuzzer-issues

Conversation

@youknowone

Copy link
Copy Markdown
Member

Follow-up to #8514, from the same fuzzing + static-review catalogs: the two thread-safety defects that #8514 left open, one leak found beside them, and the _imp follow-ups from that PR's review. One commit per defect.

_imp frozen data

_imp.get_frozen_object(name, data) decoded data with marshal::deserialize_code, which reads a bare code body, while the value importlib passes is a whole marshal value — type byte and all. Every explicit data argument therefore came back as ImportError: Frozen object named '…' is invalid. It is read with marshal.loads now; a non-buffer argument still reports TypeError.

find_frozen then implements withdata=True, which returned None before. The data it hands out is what get_frozen_object takes back, so the stored frozen module — which has its own encoding, not marshal — is re-serialized with marshal.dumps into a memoryview. Its arguments are bound by a #[derive(FromArgs)] struct instead of hand-checked FuncArgs.

data, ispkg, origname = _imp.find_frozen("__hello__", withdata=True)
_imp.get_frozen_object("__hello__", data)   # ImportError before

itertools.tee leaked its buffer

The buffer shared by the iterators of one tee() was a PyRc<PyItertoolsTeeData> — a plain refcount the collector cannot walk — so any cycle through a tee iterator was unreachable to gc and never freed:

container = []; node = Node(); container.append(node)
node.held = itertools.tee(container)[0]     # node is never collected

The buffer is now the _tee_dataobject Python type with a traverse, and tee/_tee split the way they do in CPython: tee() copies an iterator that can copy itself and only wraps one that cannot. test_itertools.test_tee loses its expectedFailure.

contextvars is shared between threads (RUSTPY-0019)

A Context's map and each ContextVar's cache are reachable from every thread that touches them, but were held in RefCell/Cells, with three unsafe impl Sync covering the hole. Concurrent set/reset/copy_context panicked on already borrowed: BorrowMutError, and ContextVar::get read the cache through AtomicCell::as_ptr while another thread wrote it.

The map and the cache now sit behind PyMutex, entered is a compare_exchange so two threads cannot both enter one Context, hash/used are atomics, and the three unsafe impl Sync are gone. A value displaced by set/reset/delete is dropped after the lock is released, so a __del__ that calls back into the same Context cannot deadlock.

Generator resume race (RUSTPY-0023)

send(), send_none(), throw() and close() read closed and frame.lasti() before running was compare_exchanged, so the frame they went on to resume could be one another thread had already advanced. A resume that concluded from lasti() == 0 that the generator had not started pushes no value onto the value stack, and the POP_TOP after the yield pops one — tried to pop from empty stack, the fatal in ExecutingFrame::run.

The compare_exchange hands back a guard now, taken before those reads and released after maybe_close(), so the generator is also retired while it is still claimed and a waiting thread cannot resume a frame that has finished.

Tests

New snippets: stdlib_threading_generator.py (four threads resuming the same generator through a barrier; every yielded value has to reach exactly one caller), stdlib_threading_contextvars.py, and a tee case in stdlib_gc.py.

test_asyncio, test_threading, test_queue, test_selectors, test_weakref, test_gc, test_context, test_generators, test_coroutines, test_asyncgen, test_yield_from, test_contextlib, test_itertools, test_marshal, test_importlib all pass; the CI clippy line is clean.

Still open

RUSTPY-0007 face 7c — the object-core segfault reported in the selectors and asyncio_queues vehicles — is not fixed here. The report has no minimal reproducer for it and marks it as needing a per-crash-dir gdb pass to separate it from the recursion face 7a, whose guards landed in #8514. Driving both vehicle surfaces (selectors with lying/raising/out-of-range fileno() from several threads, asyncio queues plus the _asyncio task registry with non-task objects), eighteen deep-nesting shapes and ten object-core abuse shapes (resurrection in __del__, weakref callbacks, __class__/__bases__ reassignment, suspended frame.clear()) produced no crash on this branch.

🤖 Generated with Claude Code

The data argument went through deserialize_code(), which reads a code body
without the type byte in front of it, so nothing marshal.dumps() produces was
accepted. Read it with marshal.loads() and require a code object back.

Assisted-by: Claude
The arity check was done by hand on a FuncArgs. Take the arguments through a
FromArgs struct instead, which makes withdata keyword-only, and fill in the
data it asks for: the frozen encoding is not marshal, so the code is
re-serialized into what get_frozen_object() reads back.

Assisted-by: Claude
The buffer was a PyRc<PyItertoolsTeeData>, which is not a Python object, so the
collector could not walk into it and any cycle running through a tee was
uncollectable. Make it the _tee_dataobject type with a traverse, held by
PyRef, and split the rest along the same lines: tee() is a function, _tee is
the iterator type it builds, and _tee takes a single iterable rather than
returning a tuple from __new__. _tee is weak-referenceable, tee() rejects a
negative n with ValueError and reserves its tuple fallibly.

test_itertools.test_tee passes now; its expectedFailure marker is removed.

Assisted-by: Claude
The variable map was a RefCell, the enter flag, the context index, the token's
used flag and the variable hash were Cells, and each carried an unsafe impl
Sync. A Context or ContextVar shared between threads overlapped their borrows
and panicked. The map and the per-variable cache are now PyMutex, the flags and
the index are atomics, entering a context is a compare_exchange, and the three
unsafe impl Sync are gone.

The cache also stopped being read through AtomicCell::as_ptr, which raced a
concurrent store on a value holding a PyObjectRef.

Values displaced from the map or the cache are dropped after the lock is
released: __del__ can come straight back into the same context, and the locks
are not reentrant.

Assisted-by: Claude
send(), send_none(), throw() and close() read `closed` and `frame.lasti()`
before `running` was compare_exchanged, so the frame they went on to resume
could be one another thread had already advanced. A resume that decided from
`lasti() == 0` that the generator had not started pushes no value onto the
value stack, and the code after the yield pops one, which underflows the stack.

The compare_exchange now hands back a guard, taken before those reads and
released after maybe_close(), so the generator is retired while it is still
claimed.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: 301421b0-e043-4ff6-8430-706f0bba846b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[x] test: cpython/Lib/test/test_itertools.py (TODO: 3)

dependencies:

dependent tests: (56 tests)

  • itertools: test_annotationlib test_ast test_asyncio test_bdb test_buffer test_builtin test_call test_codeccallbacks test_collections test_compile test_concurrent_futures test_csv test_ctypes test_descr test_dis test_email test_exceptions test_functools test_genericalias test_hashlib test_heapq test_httplib test_importlib test_inspect test_io test_iterlen test_itertools test_launcher test_logging test_math test_memoryview test_mmap test_os test_peepholer test_platform test_pprint test_pyrepl test_queue test_range test_set test_shlex test_slice test_socket test_sort test_statistics test_str test_struct test_subprocess test_tokenize test_tuple test_typing test_unittest test_uuid test_winreg test_xml_etree test_zipfile

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant