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 — Free-threaded (no-GIL): dumps() crashes when another thread mutates the dict being serialized (PyDict_Next iterator invalidation) · Issue #234 · python-rapidjson/python-rapidjson Open DescriptionAhead of the free-threading work in #225: Latent today, not live. The extension does not declare Site (current
|
| 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.
"""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())
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.
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.
Thank you.
The FT branch definitely requires some focused effort, something I cannot grant these days... so any help would be highly appreciated!
Happy to help — here is what I found looking at the free-threaded branch, with the
measurements behind it.
The branch's only change to rapidjson.cpp is the module declaration:
#ifdef Py_GIL_DISABLED {Py_mod_gil, Py_MOD_GIL_NOT_USED}, #endif
That tells the interpreter not to re-enable the GIL, but the container walks are still
unlocked — so it converts this issue from latent into reachable as built. Measured on
python3.14.0rc1t with the module built from 8798483, no PYTHON_GIL override,
10 runs per arm:
| arm | result |
|---|---|
| mutate the dict being dumped | 10/10 SIGSEGV |
| control — no mutator thread | clean 10/10 |
| control — mutate a different dict | clean 10/10 |
control — same test, PYTHON_GIL=1 |
clean 10/10 |
On master the same script needs PYTHON_GIL=0 to crash. The GIL-on control staying
clean is what makes it a free-threading fault rather than a generic race.
dumps_internal walks lists the same way — size captured once, then the unchecked macro:
Py_ssize_t size = PyList_GET_SIZE(object); // 2513 for (Py_ssize_t i = 0; i < size; i++) { PyObject* item = PyList_GET_ITEM(object, i); // 2518 — no bounds check bool r = RECURSE(item);
A concurrent shrink of the list being dumped makes the loop read past the end. Same
4-arm result: 10/10 SIGSEGV on the branch with no override, all three controls clean.
So there are four walk sites to protect: all_keys_are_string (2326), the two dict
walks in dumps_internal (2559, 2599), and this list walk (2513–2518).
So the list above is not just the scary-looking greps:
static PyObject* globals (46–79) are assigned once inmodule_exec and only read afterwards, which is safe under free-threading. They are am_size = 0), i.e. Extension isolation and subinterpreters support #226, not this issue.PyHandler members (root, sharedKeys, the decoder* fields) and thePy_SETREF/Py_CLEAR on them: a PyHandler is per decode call, so those areFor each walk, either hold a critical section on the container:
Py_BEGIN_CRITICAL_SECTION(object); while (PyDict_Next(object, &pos, &key, &item)) { ... } Py_END_CRITICAL_SECTION();
or snapshot before walking (PyDict_Items() / PySequence_Tuple()). StringZilla just
took the snapshot route for exactly this class of bug. A critical section is cheaper,
but note it is released around re-entrant calls, and the loop body here can re-enter
Python through a default= callable — so for the dumps_internal walks a snapshot is
the more robust choice, while all_keys_are_string (which calls no Python) is fine with
a critical section.
The one ordering I would avoid: landing Py_MOD_GIL_NOT_USED before the walks are
protected, since that is what removes the interpreter-level protection currently keeping
these latent.
If it is useful I can put up a PR against free-threaded with those four sites fixed
plus a 3.14t CI job, and leave the Py_MOD_GIL_NOT_USED decision to you as a separate
commit you can flip when you are satisfied. Reproducers for both crashes are small and
I can include them. Entirely your call on shape — happy to follow whatever you prefer,
or to just hand over the reproducers if you would rather do the fix yourself.
(Analysis done with AI assistance; the measurements are mine and reproducible.)