Ahead of the free-threading work in #225: dumps() walks the object being
serialized with PyDict_Next, which is documented as unsafe against concurrent
mutation. On a free-threaded interpreter, another thread structurally mutating that
dict while dumps() is walking it corrupts the walk and crashes.
Latent today, not live. The extension does not declare Py_MOD_GIL_NOT_USED,
so importing it on a free-threaded build re-enables the GIL and this is safe as
shipped. It becomes reachable when the GIL is forced off (PYTHON_GIL=0) — i.e.
the moment free-threading support (#225) lands. So this is a concrete, reproducible
instance of what #225 needs to address, not a bug in current default behaviour.
Site (current master, rapidjson.cpp)
static inline bool all_keys_are_string(PyObject* dict) { // 2323
Py_ssize_t pos = 0; PyObject* key;
while (PyDict_Next(dict, &pos, &key, NULL)) // walks the user dict, no copy, no lock
if (!PyUnicode_Check(key)) return false;
dumps(d) walks d here and again in dumps_internal (2556 / 2596). pos indexes
the dict's internal table; a structural mutation from another thread (an insert that
triggers a resize, or a delete) reallocates or reorders that table, so pos then
points into freed / relocated memory.
Reproducer
python3.14.0rc1t, python-rapidjson 1.23 built from source, PYTHON_GIL=0. 6
threads call dumps(shared) while 3 threads structurally mutate shared. 10
rounds per arm:
| arm |
result |
mutate, GIL off (PYTHON_GIL=0) |
10/10 SIGSEGV |
| control — no mutator thread |
clean 10/10 |
control — mutate a different dict dumps() never sees |
clean 10/10 |
| control — same mutation, GIL left on |
clean 10/10 |
The GIL-on control staying clean is the whole point: this is a free-threading
fault, not a generic race, and it is safe under the GIL that ships today.
ft_rapidjson.py
"""python-rapidjson 1.23 — iterator-invalidation in dumps() over a shared dict.
`rapidjson.cpp:2320` all_keys_are_string() and dumps_internal() (2553/2593) walk
the user dict with `PyDict_Next`, no copy and no lock:
while (PyDict_Next(dict, &pos, &key, NULL)) // 2320
if (!PyUnicode_Check(key)) return false;
`PyDict_Next` is documented unsafe against concurrent mutation: `pos` indexes the
dict's internal table, and a structural mutation (insert that triggers a resize,
or a delete) reallocates or reorders that table, so a `pos` captured before the
mutation now points into freed/relocated memory.
Under the GIL, `dumps()` never yields mid-walk, so it is safe. Under
`Py_GIL_DISABLED`, another thread structurally mutating the same dict while
`dumps()` walks it invalidates the iteration → crash or garbage read.
This is the iterator-invalidation class, not borrow-then-use: the corrupted thing
is the *walk's cursor over the container*, not a single borrowed item.
Run with PYTHON_GIL=0 (the module does not declare Py_MOD_GIL_NOT_USED, so import
re-enables the GIL otherwise).
"""
import os
import sys
import threading
import time
import rapidjson
N_DUMPERS = int(os.environ.get("N_DUMPERS", 6))
N_MUTATORS = int(os.environ.get("N_MUTATORS", 3))
SIZE = int(os.environ.get("SIZE", 64))
SECONDS = float(os.environ.get("SECONDS", 6))
MUTATE = os.environ.get("MUTATE", "1") == "1"
DECOY = os.environ.get("DECOY", "0") == "1"
def fresh(n):
return {f"k{i}": i for i in range(n)}
def main():
gil = getattr(sys, "_is_gil_enabled", lambda: True)()
print(f"py={sys.version.split()[0]} gil={gil} "
f"dumpers={N_DUMPERS} mutators={N_MUTATORS if MUTATE else 0} "
f"decoy={DECOY}", flush=True)
shared = fresh(SIZE)
decoy = fresh(SIZE)
stop = threading.Event()
barrier = threading.Barrier(N_DUMPERS + (N_MUTATORS if MUTATE else 0) + 1)
dumped = [0] * N_DUMPERS
def dumper(t):
barrier.wait()
n = 0
while not stop.is_set():
try:
rapidjson.dumps(shared)
except Exception:
pass # value/type errors from racing data are irrelevant
n += 1
dumped[t] = n
def mutator():
barrier.wait()
target = decoy if DECOY else shared
i = 0
while not stop.is_set():
# STRUCTURAL mutation: grow (insert → resize) and shrink (delete),
# which is what invalidates the PyDict_Next cursor.
target[f"x{i}"] = i
if i % 3 == 0 and len(target) > SIZE:
try:
target.pop(next(iter(target)))
except (KeyError, StopIteration, RuntimeError):
pass
i += 1
threads = [threading.Thread(target=dumper, args=(t,)) for t in range(N_DUMPERS)]
if MUTATE:
threads += [threading.Thread(target=mutator) for _ in range(N_MUTATORS)]
for th in threads:
th.start()
barrier.wait()
time.sleep(SECONDS)
stop.set()
for th in threads:
th.join()
print(f"clean: {sum(dumped)} dumps survived", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
A possible fix (when #225 is worked)
Take a critical section on the dict for the duration of the walk
(Py_BEGIN_CRITICAL_SECTION(dict) around the PyDict_Next loops), or snapshot the
keys/items before serializing. all_keys_are_string and the two dumps_internal
walks are the sites.
Not claimed
No severity — reachable only with the GIL disabled, which is not the default today.
No exploitability — the observed faults are consistent with the mechanism; I did not
build a primitive.
Ahead of the free-threading work in #225:
dumps()walks the object beingserialized with
PyDict_Next, which is documented as unsafe against concurrentmutation. On a free-threaded interpreter, another thread structurally mutating that
dict while
dumps()is walking it corrupts the walk and crashes.Latent today, not live. The extension does not declare
Py_MOD_GIL_NOT_USED,so importing it on a free-threaded build re-enables the GIL and this is safe as
shipped. It becomes reachable when the GIL is forced off (
PYTHON_GIL=0) — i.e.the moment free-threading support (#225) lands. So this is a concrete, reproducible
instance of what #225 needs to address, not a bug in current default behaviour.
Site (current
master,rapidjson.cpp)dumps(d)walksdhere and again indumps_internal(2556 / 2596).posindexesthe dict's internal table; a structural mutation from another thread (an insert that
triggers a resize, or a delete) reallocates or reorders that table, so
posthenpoints into freed / relocated memory.
Reproducer
python3.14.0rc1t, python-rapidjson 1.23 built from source,PYTHON_GIL=0. 6threads call
dumps(shared)while 3 threads structurally mutateshared. 10rounds per arm:
PYTHON_GIL=0)dumps()never seesThe GIL-on control staying clean is the whole point: this is a free-threading
fault, not a generic race, and it is safe under the GIL that ships today.
ft_rapidjson.py
A possible fix (when #225 is worked)
Take a critical section on the dict for the duration of the walk
(
Py_BEGIN_CRITICAL_SECTION(dict)around thePyDict_Nextloops), or snapshot thekeys/items before serializing.
all_keys_are_stringand the twodumps_internalwalks are the sites.
Not claimed
No severity — reachable only with the GIL disabled, which is not the default today.
No exploitability — the observed faults are consistent with the mechanism; I did not
build a primitive.