Stop items() iteration from reading a freed entry in CIMultiDict by asvetlov · Pull Request #1496 · aio-libs/multidict · GitHub
Skip to content

Stop items() iteration from reading a freed entry in CIMultiDict - #1496

Merged
asvetlov merged 3 commits into
aio-libs:masterfrom
asvetlov:claude/multidict-ci-failure-605017
Sep 21, 2026
Merged

asvetlov merged 3 commits into
aio-libs:masterfrom
asvetlov:claude/multidict-ci-failure-605017

Conversation

@asvetlov

@asvetlov asvetlov commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

What do these changes do?

Fixes a use-after-free in the C extension's key conversion that crashed the cp315t wheel test on master (job, list(md.items()) in test_race_condition_iterator_vs_mutation).

When iterating a CIMultiDict whose stored key is a plain str, _md_ensure_key() caches the istr in the entry, drops the old key, and then read entry->key again. Dropping the old key can run a str subclass's __del__, or on free-threaded builds suspend the iterator's critical section; either lets the table be mutated and the entry freed before that reload. Building the istr itself can run a str subclass's __str__, which freed entry->identity while IStr_New() was still using it.

_md_ensure_key() now holds its own references across the conversion, only caches the istr if the version is unchanged, and returns the computed key instead of reloading it. md_clone_from_ht() now assigns a new version instead of copying the source's, so a __str__ that calls d.__init__(d.copy()) can no longer restore the version that check relies on; the pure Python implementation already behaved this way. md_next(), md_prev() and both finders read the value before the key, so the decrefs are the last access to the entry. The pure Python implementation does not cache the converted key and is unaffected.

Are there changes in behavior for the user?

No, apart from the crash being gone. getversion() of a copy() or of a multidict re-initialized from another one now differs from the source's on the C extension too, as it already did in pure Python. A mutation from a key's __str__/__del__ during iteration now surfaces as the usual RuntimeError: MultiDict is changed during iteration.

Is it a substantial burden for the maintainers to support this?

No.

Related issue number

N/A

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt (N/A)
  • Add a new news fragment into the CHANGES/ folder
  • make doc-spelling passes and any new technical words are added to docs/spelling_wordlist.txt

Drafted with Claude Code (Claude Opus 5); reviewed by @asvetlov.

Agent run details (optional, for reviewers)

Both new tests segfault the unpatched extension on a plain CPython 3.14 release build; ASan pins them to hashtable.h:869 (Py_NewRef(entry->key)) and IStr_New().

With the patch:

  • CPython 3.14, C extension: 2069 passed
  • CPython 3.14, pure Python (--no-c-extensions): 1042 passed
  • CPython 3.14, MULTIDICT_DEBUG_BUILD=1 MULTIDICT_ASAN_BUILD=1, PYTHONMALLOC=malloc (run without -I, which would drop it): 2058 passed, no ASan/UBSan reports
  • CPython 3.14.7t, MULTIDICT_DEBUG_BUILD=1: 2072 passed

Free-threaded mechanism: with a Py_BEGIN_ALLOW_THREADS + nanosleep() window injected right after the old key decref (not committed), a 4-thread CIMultiDict writer/reader stress on 3.14.7t segfaulted 10/10 runs before the fix and 0/10 after.

make doc-spelling and pre-commit pass.

_md_ensure_key() replaced a cached plain str key with its istr and then
read entry->key again after dropping the old key. That decref can run a
str subclass's __del__, or suspend the critical section on free-threaded
builds, either of which lets the table be mutated and the entry freed.
Building the istr has the same problem through a str subclass's __str__,
which also invalidated entry->identity while IStr_New() still used it.

Hold our own references across the conversion, only cache the istr if
the version is unchanged, return the computed key directly, and have
every caller read the value before the key so the decrefs are the last
entry access.
@codspeed

codspeed Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

@asvetlov
asvetlov marked this pull request as ready for review September 21, 2026 09:53
@asvetlov
asvetlov requested a review from webknjaz as a code owner September 21, 2026 09:53
@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (cd528d6) to head (aa98110).

Additional details and impacted files
@@            Coverage Diff            @@
##            master     #1496   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           37        37           
  Lines         6021      6069   +48     
  Branches       538       538           
=========================================
+ Hits          6021      6069   +48     
Flag Coverage Δ
CI-GHA 100.00% <100.00%> (ø)
pytest 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@greptile-apps

greptile-apps Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

Safe to merge.

Reviews (2) · Last reviewed commit: "Give cloned multidicts a new version"

Comment thread multidict/_multilib/hashtable.h
@greptile-apps

This comment has been minimized.

md_clone_from_ht() copied the source's version, so a str subclass's
__str__ calling d.__init__(d.copy()) during key conversion restored the
version _md_ensure_key() checked, and the istr was written through the
freed entry. A clone now takes a fresh version, as the pure Python
implementation already does.
@asvetlov

Copy link
Copy Markdown
Member Author

@aiolibsbot rr

@asvetlov
asvetlov merged commit 2a68472 into aio-libs:master Sep 21, 2026
61 checks passed
@asvetlov
asvetlov deleted the claude/multidict-ci-failure-605017 branch September 21, 2026 11:04
@aiolibsbot

Copy link
Copy Markdown
Contributor

asvetlov added a commit that referenced this pull request Sep 21, 2026
<!-- Thank you for your contribution! -->

## What do these changes do?

The ASan test command in `AGENTS.md` and in the CI `asan` job ran
`python -Im pytest` with `PYTHONMALLOC=malloc`. `-I` implies `-E`, so
Python ignored `PYTHONMALLOC` and kept serving small allocations from
pymalloc arenas. Most `htkeys_t` tables are 512 bytes or less, so ASan
could not detect use-after-free on them. This drops `-I` in both places
and adds a short note explaining why it must stay out. The TSan command
and job, and the `Makefile`, do not combine `-I`/`-E` with
`PYTHONMALLOC` and are unchanged.

## Are there changes in behavior for the user?

No. Contributor-facing only.

## Is it a substantial burden for the maintainers to support this?

No.

## Related issue number

N/A

## Checklist

- [x] I think the code is well written
- [ ] Unit tests for the changes exist: N/A, docs and CI invocation only
- [x] Documentation reflects the changes
- [ ] If you provide code modification, please add yourself to
`CONTRIBUTORS.txt`: N/A
- [x] Add a new news fragment into the `CHANGES/` folder
- [x] `make doc-spelling` passes and any new technical words are added
to `docs/spelling_wordlist.txt`

Drafted with Claude Opus 5 (Claude Code); reviewed by @asvetlov.

<details>
<summary>Agent run details (optional, for reviewers)</summary>

Allocator check on CPython 3.13.2: `PYTHONMALLOC=malloc python -c
...pymem_getallocatorsname()` reports `malloc`; with `-I` it reports
`pymalloc`.

Earlier manual check:
`tests/test_multidict.py::test_items_iter_key_str_reinits` (from #1496)
hits a heap-use-after-free on the pre-fix code under `python -m pytest`,
but passes under `python -Im pytest`.

Corrected command on current master, ASan+UBSan build, isolated 3.13.2
venv: `LD_PRELOAD=$ASAN_SO ASAN_OPTIONS=detect_leaks=0:halt_on_error=1
PYTHONMALLOC=malloc python -m pytest tests -q --no-cov -k "not
test_leak"` gave 2048 passed, 21 skipped, 11 deselected. So turning on
real malloc in CI does not make master fail.

`make doc-spelling`: passed. Pre-commit hooks: passed.
</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants