GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — Protect the container walks in dumps() against concurrent mutation (#234) by espressolee · Pull Request #235 · python-rapidjson/python-rapidjson Conversation
|
| site | fix | why this one |
|---|---|---|
all_keys_are_string() |
critical section on the dict | the loop calls no Python, so a section is enough and cheap. Restructured to a single exit — returning from inside a critical section would leak it |
the list walk in dumps_internal() |
re-read the length each iteration, PyList_GetItemRef() |
the unchecked macro over a size captured once reads past the end after a concurrent or re-entrant shrink; PyList_GetItemRef is bounds-checked and returns a strong reference |
the two dict walks in dumps_internal() |
on free-threaded builds, iterate a snapshot from PyDict_Items(); GIL builds retain PyDict_Next streaming |
a critical section is not sufficient on free-threaded builds: these bodies call back into Python (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 it. The #ifdef Py_GIL_DISABLED boundary avoids charging default builds for a safety mechanism they do not need |
In rapidjson.cpp, the loop bodies are unchanged — only the headers and item acquisition move.
python3.14.0rc1t, module built from this branch, no PYTHON_GIL override, 10 runs
per arm:
| reproducer | before | after |
|---|---|---|
| mutate the dict being dumped (#234) | 10/10 SIGSEGV | 0/10 |
| mutate the list being dumped | 10/10 SIGSEGV | 0/10 |
| control — no mutator | clean | clean |
| control — mutate a different object | clean | clean |
control — same test, PYTHON_GIL=1 |
clean | clean |
The list bug also reproduces without concurrency: on released python-rapidjson 1.23, a
single-threaded default= callback that shrinks the list being encoded crashed 5/5 on stock
CPython 3.11, 3.12, and 3.13, and 10/10 on 3.14.6. Same-object controls were positive while
no-mutation, different-object, append-only, and tuple controls were clean. This branch was
0/10 in both GIL and free-threaded modes for the same reproducer.
pytest tests/ on the patched free-threaded build: 914 passed, 26 skipped, 2 xfailed.
Same interpreters, same data, nine timed samples per case, median ns/dump:
| build | flat 16 | flat 1024 | nested 128×8 | peak delta, flat 1024 |
|---|---|---|---|---|
| GIL, conditional patch vs base | -1.1% | +0.4% | +0.9% | 0 bytes |
| no-GIL, snapshot patch vs base | +3.7% | +1.9% | +24.2% | +65,592 bytes |
Compatibility/test matrix after the follow-up: Python 3.12.13 and 3.14.6 each
923 passed, 17 skipped, 2 xfailed; Python 3.14.0rc1t
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_internal dict walks rather than to all_keys_are_string.
Exact-head workflow run 32349040222
completed successfully for 7eb5586a477535afbff4c356f9dc104cb6854d80:
Py_MOD_GIL_NOT_USED declaration — it is already on this branch andPyDict_Items walks are therefore behind#ifdef Py_GIL_DISABLED; GIL builds retain PyDict_Next streaming. After that change,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.)
Sorry, something went wrong.
There was an error while loading. Please reload this page.
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 <[email protected]>
|
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"? |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
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 |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
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 |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Thank you. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
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. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Measured snapshot and compatibility follow-upUpdated the existing branch; no new PR. Current head: The original uniform
I therefore made only the two dict snapshots conditional on
The free-threaded cost remains the explicit price of owning a stable snapshot; the GIL build no longer pays it. The first CI run then exposed that Local verification:
CI run #166 for |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Still clean at Since my last update, I noticed that I'm happy to retarget/rebase this onto The remaining design choice from my 8/20 note is whether the two dict snapshots should stay conditional on |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.