Protect the container walks in dumps() against concurrent mutation (#234) - #235
Protect the container walks in dumps() against concurrent mutation (#234)#235espressolee wants to merge 2 commits into
Conversation
On a free-threaded build the three walks in dumps_internal() and the one in all_keys_are_string() read the caller's container without holding anything, so another thread mutating it mid-walk reads freed or out-of-bounds memory. This branch declares Py_MOD_GIL_NOT_USED, which keeps the GIL disabled, so those walks become reachable rather than latent. - all_keys_are_string(): the loop calls no Python, so a critical section on the dict is enough. Restructured to a single exit, since returning from inside a critical section would leak it. - the list walk: re-read the length each iteration and take a strong reference via PyList_GetItemRef() instead of indexing with the unchecked macro over a size captured once. - the two dict walks: iterate a snapshot from PyDict_Items(). A critical section is not enough here because the bodies call back into Python (RECURSE, PyObject_Str), during which a critical section is suspended. A small RAII guard owns the snapshot so the existing early returns cannot leak it. Measured on python3.14.0rc1t with this branch built, no PYTHON_GIL override, 10 runs per arm. Dict walk: 10/10 SIGSEGV before, 0/10 after. List walk: 10/10 SIGSEGV before, 0/10 after. Controls (no mutator / mutate a different object / same test with PYTHON_GIL=1) clean throughout. Test suite: 914 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thank you! Everything seems good here. On the CI: yes, I think it should be added, especially because I do not have (yet?) use cases requiring the FT interpreter. (Note for myself) On the snapshot cost: it would be interesting, even only to declare it in the documentation, to measure the impact, ideally adding it to the benchmarks tables. Last, could you please help me understand the third point above, "No claim about re-entrancy", as I'm having difficulties grasping the meaning of the final part, "it did not reproduce, so I am not asserting a pre-existing bug there"? |
|
Thanks — and taking the third point first, because the honest answer is that the sentence What I meant by "re-entrancy"Not a second thread. One thread, hurting itself: The clause you quoted meant: I tried one such case, it did not crash, and I did not want the The list case reproduces, on the released version, with the GILimport rapidjson
class X: pass
lst = [X() for _ in range(256)]
def default(o):
del lst[128:] # shrink the list dumps() is walking, once
return None
rapidjson.dumps(lst, default=default)python-rapidjson 1.23 from PyPI, stock CPython,
Controls on 3.14.6, 8 runs each, same script, one line different:
The cause is the pair of lines this PR already changes: Py_ssize_t size = PyList_GET_SIZE(object);
for (Py_ssize_t i = 0; i < size; i++) {
...
PyObject* item = PyList_GET_ITEM(object, i);
bool r = RECURSE(item);
I have not pinned the precise interleaving beyond that; the measured facts are the tables What that means for this PRThe list hunk here already fixes it: same reproducer against this branch is 0/10 in both The awkward part is targeting. This PR is against I searched the tracker (open and closed, issues and PRs) for re-entrancy, segfault, crash, The dict half stayed negativeFor completeness, since this is what the original sentence was actually about. Five So I still have no dict re-entrancy bug to show you. I would not read that as proof there To make the harness worth anything I ran a known-positive first: the #234 concurrent Your other two pointsCI — glad to add it. Two independent pieces, and I'd rather you tell me which you want Snapshot cost — agreed that it should be measured before it is documented. I'll benchmark (As on the PR itself: developed with AI assistance; the measurements are mine, run on this |
PyList_GetItemRef() sets IndexError when the index is out of range, and the NULL branch added in the previous commit broke out of the loop without clearing it. dumps() then returned a string with a live exception, which the caller sees as a spurious "IndexError: list index out of range". Measured on a free-threaded build with a thread shrinking the list being dumped: 12 runs of 12 raised it before this change, 0 of 12 after. The test suite is unchanged at 914 passed, 26 skipped, 2 xfailed.
|
One correction to the branch, found while preparing the The Measured on a free-threaded build, one thread calling
Pushed as a separate commit so the reviewed one stays legible. Test suite unchanged: 914 One judgement call in it, and I'd rather you make it than me. Stopping quietly means a Sorry for reviewing-then-amending. The probe that caught it did not exist when you looked at |
|
Thank you. |
|
On "what other libraries do" — there is a fairly direct precedent, and it cuts against the detect-and-raise side. ultrajson#689, "thread safety fixes for free-threading", was opened by Kumar Aditya (a CPython core developer) in October 2025 and closed unmerged on 2026-05-28, after roughly seven months of discussion. That is essentially the approach you are weighing: notice the concurrent mutation and surface it rather than let the walk read freed memory. It did not land, and the reasoning there is worth reading directly rather than through my summary. The other data point is what CPython did to its own instances. cpython#149816 collects a batch of free-threading races, and the fixes from it — cpython#149909 for example — do not detect misuse and raise. They make the access itself atomic, replacing a borrow-then-incref with PyList_GetItemRef. Faced with the same choice inside the interpreter, upstream took the "make the read safe" route rather than the "report the user's mistake" route. That does not settle it for a third-party serializer, and I would rather not push. Your framing — that a caller mutating a structure mid-dump is doing something wrong — is a real position, and ujson's documentation takes it too. The one asymmetry I would flag is this: a raise is a behaviour change your users can see and argue with, whereas the current state is a use-after-free they cannot see and cannot defend against. Those are not symmetric in cost even where they are symmetric in principle. Happy to leave it there. The CI half is merged and that was the part I had a real stake in; whatever you decide on the judgement call, I will not read a slower answer as a rejection. |
Following up on #234 with the fix, since you said help would be welcome. Against
free-threadedrather thanmaster, because it is the GIL declaration on this branchthat makes these reachable.
What it changes
Four container walks in
dumps()read the caller's object without holding anything:all_keys_are_string()dumps_internal()PyList_GetItemRef()PyList_GetItemRefis bounds-checked and returns a strong referencedumps_internal()PyDict_Items()RECURSE,PyObject_Str), and a critical section is suspended when the thread blocks. A small RAII guard owns the snapshot so the eight existing early returns cannot leak itThe loop bodies are unchanged — only the headers and the item acquisition move.
Measured
python3.14.0rc1t, module built from this branch, noPYTHON_GILoverride, 10 runsper arm:
PYTHON_GIL=1pytest tests/on the patched build: 914 passed, 26 skipped, 2 xfailed.Intermediate measurement, in case it is useful: with only the first two fixes applied the
dict reproducer still crashed 8/10, which is what pinned the remaining crashes to the two
dumps_internaldict walks rather than toall_keys_are_string.What I did not do
cibuildwheelis easy but it is your matrix andyour build minutes, so I left it out rather than guess. Happy to add it in this PR or a
separate one.
Py_MOD_GIL_NOT_USEDdeclaration — it is already on this branch andI did not touch it. With these four sites protected the ordering is now the safe way
round; before them, the declaration was what removed the interpreter-level protection.
serialized. If you would rather not pay that on the GIL build, the two
PyDict_Itemscalls can go behind
#ifdef Py_GIL_DISABLEDwith the current streaming walk kept forGIL builds — say the word and I will restructure it that way. I did it uniformly here
because it is the smaller, more readable diff, but the performance call is yours.
default=callable that mutates thedict being dumped corrupts the walk under the GIL; it did not reproduce, so I am not
asserting a pre-existing bug there.
Reshape any of this however you prefer — including rejecting the snapshot approach for
the dict walks, which is the one real design choice in here.
(Fix developed with AI assistance; the measurements are mine and reproducible.)