Crash report
What happened?
Doc/c-api/dict.rst tells a PyDict_WatchCallback two things:
The callback may inspect but must not modify dict; doing so could have unpredictable effects, including infinite recursion. Do not trigger Python code execution in the callback, as it could modify the dict as a side effect.
— dict.rst:582-584
If the callback sets an exception, it must return -1; this exception will be printed as an unraisable exception using PyErr_WriteUnraisable.
— dict.rst:595-596
A callback that does exactly the second thing causes CPython to do exactly the first thing on its behalf. _PyDict_SendEvent (Objects/dictobject.c:8309-8317):
if (cb && (cb(event, (PyObject*)mp, key, value) < 0)) {
PyErr_FormatUnraisable(
"Exception ignored in %s watcher callback for <dict at %p>",
dict_event_name(event), mp);
}
PyErr_FormatUnraisable runs sys.unraisablehook, which is settable from pure Python. So the "do not trigger Python code execution" requirement is discharged by the callback and then violated by the runtime — inside the notify window, while the dict is mid-update.
The docs do say the exception becomes an unraisable. What they don't say is that this happens synchronously, inside the operation, at a point where the calling code has already captured an entry index, an entry pointer, a bound, or a borrowed reference that it will use afterwards.
Thirteen of the fourteen notify sites hold stale state across that window
I drove all fourteen. Thirteen hold state across the notify and all thirteen produce an observable failure; twelve of those are memory corruption or a memory-invariant assertion. One site is safe.
| site |
enclosing function |
stale value |
worst observed |
:1917 |
insert_combined_dict |
dk_usable bound |
ASan heap-buffer-overflow WRITE, "0 bytes after" a 1520-byte region; release: silent corruption, exit 0 |
:1997 |
_PyDict_InsertSplitValue |
ix, split-table-ness |
SIGSEGV at :1998, ma_values == NULL — 5/5 on each of four builds |
:2003 |
_PyDict_InsertSplitValue |
ix, old_value |
SIGSEGV at :2004 5/5 on four builds; embedded variant asserts in clear_freelist |
:2060 |
insertdict |
ix, old_value |
ASan heap-UAF READ at :2076; heap-buffer-overflow WRITE at :2068; _Py_NegativeRefcount 5/5 |
:2103 |
insert_to_emptydict |
unpublished newkeys |
ma_used desync puts a NULL in a list → SIGSEGV in list_sort_impl |
:3038 |
_PyDict_DelItem_KnownHash_LockHeld |
ix |
debug assert(hashpos >= 0); release Py_DECREF(NULL) |
:3083 |
delitemif_lock_held |
ix, old_value, hash |
ASan SEGV Py_DECREF(NULL); len() → NULL 5/5 release |
:3142 |
clear_lock_held |
keys pointer |
ASan use-after-free at dictkeys_decref:496 |
:3307 |
_PyDict_Pop_KnownHash |
ix, old_value |
ASan heap-UAF READ at :3308; SIGSEGV 5/5 release |
:4234 |
dict_dict_merge |
the whole guard set |
debug assert in clone_combined_dict_keys; silent data loss 5/5 — no memory corruption reached |
:5051 |
dict_popitem_impl (unicode) |
ep0 |
ASan UAF R+W; popitem() returns a tuple whose [1] is a raw C NULL |
:5066 |
dict_popitem_impl (general) |
ep0, i |
ASan heap-UAF READ at the site, :5067 |
:7510 |
store_instance_attr_lock_held |
values, dict, ix, old_value |
assert(i < size); ASan overflow WRITE + UAF READ; len(), list() and vars() all raise |
:3652 |
dict_dealloc |
(none) |
safe — reads ma_values/ma_keys at :3656-3657, after the notify |
Also reached from these paths: delete_index_from_values:2943, whose search loop is bounded only by assert(i < size) — debug SIGABRT, and on plain release it silently loses a dict entry.
Four that are worth calling out individually
:1997/:2003 are in _PyDict_InsertSplitValue, which is PyAPI_FUNC and carries the comment // Exported for external JIT support. The hook that kills it is d.clear() after del obj has detached the dict, which makes clear_lock_held take its set_values(mp, NULL) branch; the split-table store then dereferences NULL. gdb confirms ma_values == 0x0, ma_keys == &empty_keys_struct, ix == 2.
:3083 breaks a promise the file makes in a comment. dictobject.c:3090-3094 tells callers the predicate → deletion sequence is atomic provided the predicate does not re-enter. is_dead_weakref does not re-enter; the notify does. Reachable as _weakref._remove_dead_weakref(dct, key).
:3307 hands freed memory back to Python. *result at :3312 is the dict's own reference, returned straight to the dict.pop() caller after the hook dropped it.
:7510 gives the sharpest Python-visible invariant break. delete_index_from_values does size-- on a size the hook zeroed, and values->size is a uint8_t, so it wraps to 255 while :7527 drives ma_used to -1. After a plain del o.attr, on release-gil-nojit, 5/5:
len(d) -> SystemError: <built-in function len> returned NULL without setting an exception
list(d) -> ValueError: length must be positive
vars(o) -> ValueError: length must be positive
The one I could not corrupt
:4234 (dict_dict_merge, PyDict_EVENT_CLONED) holds every guard established at :4219 and :4228-4232 across the notify, and those guards are genuinely unchecked afterwards — but I could not reach memory corruption in roughly nine hook variants (0/3 under ASan). The two guards whose falsification would give a type-confused dict are out of reach from Python: mp is a plain dict so a hook cannot make mp->ma_values non-NULL, and a combined other cannot be turned back into a split table. What it does give is a 5/5 debug assertion violation in clone_combined_dict_keys and 5/5 silent data loss on all four builds — d.update(src) discards everything the hook wrote to d, with no error. On release, that path also leaves d owning a heap keys object carrying _Py_DICT_IMMORTAL_INITIAL_REFCNT (memcpy'd from empty_keys_struct), which can never be freed and permanently falsifies clear_lock_held's assert(oldkeys->dk_refcnt == 1) at :3148.
Reproducer
The callback is CPython's own — dict_watch_callback_error in Modules/_testcapi/watchers.c:90-98, in its entirety:
static int
dict_watch_callback_error(PyDict_WatchEvent event, PyObject *dict,
PyObject *key, PyObject *new_value)
{
PyErr_SetString(PyExc_RuntimeError, "boom!");
return -1;
}
That is the documented protocol and nothing else: it sets an exception, returns -1, and runs no Python. The re-entry comes entirely from CPython's response to it.
import sys, _testcapi
d = {}
for i in range(200):
d["k%d" % i] = i
fired = []
def hook(unraisable):
if fired:
return
fired.append(1)
d.clear() # re-enter the dict being mutated
sys.unraisablehook = hook
wid = _testcapi.add_dict_watcher(1) # installs dict_watch_callback_error
_testcapi.watch_dict(wid, d)
del d["k100"] # notify -> -1 -> unraisable -> hook -> clear()
print("len now %d" % len(d))
# debug build -- exit 134 (SIGABRT)
python: Objects/dictobject.c:2963: void delitem_common(...): Assertion `hashpos >= 0' failed.
# release build -- exit 1
SystemError: <built-in function len> returned NULL without setting an exception
One per-site reproducer for each of the thirteen is attached, along with negative controls — for :2060, hooks that store immortal small ints or force a resize do not crash it, so the claim is not "any hook corrupts anything". That site needs heap values specifically: with small ints the stale Py_XDECREF(old_value) at :2076 is a no-op because they are immortal, which is why an earlier attempt at this site came back clean.
Registering a watcher requires the C API, so this is not reachable from pure Python alone — but sys.unraisablehook is pure Python, and any extension that installs a conforming watcher hands that reach to arbitrary Python code.
The one safe site, and why
dict_dealloc (:3650-3658) is the only notify site that survives this. It brackets the notify in _PyObject_ResurrectStart/End, but that bracket defends against resurrection — the comment at _PyDict_SendEvent:8309-8312 names that threat and only that threat. Its mutation-safety is incidental: it re-reads ma_keys/ma_values after the notify rather than before.
So the thing to propagate is the ordering, not the bracket.
Notes toward a fix
Three directions, not mutually exclusive:
- Re-validate after the notify at each of the thirteen sites — reload the index, entry pointer, or bound. Correct but repetitive, and easy to regress: this is thirteen independent chances to get it wrong again.
- Defer the unraisable until the dict operation completes, so the callback's error is still reported but not from inside the window.
- Guard the re-entry centrally in
format_unraisable_v (Python/errors.c:1737), which is the single choke point every unraisable site funnels through.
Given that thirteen of fourteen sites are affected, the central options look more attractive than the per-site one.
Objects/typeobject.c:1219 already carries the observation, added in gh-127266:
// Note that PyErr_FormatUnraisable is potentially re-entrant and the watcher
// callback might be too
dictobject.c has no equivalent, and its notify sites were never audited against it.
Related
- CPython's own reference watcher,
dict_watch_callback (watchers.c:31-70), violates both halves of the documented contract: PyUnicode_FromFormat("new:%S:%S", ...) calls PyObject_Str (forbidden by dict.rst:583-584), and it calls PyUnicode_FromFormat + PyList_Append with no save/restore (required by dict.rst:599-603). If the example cannot follow the contract, it is worth asking whether the contract is stateable.
PyType_WatchCallback (Doc/c-api/type.rst:138-155) documents no error protocol at all, yet typeobject.c:1222 treats < 0 as "exception set" and reports it the same way.
Found with [cpython-review-toolkit]](https://github.com/devdanzin/cpython-review-toolkit); reduced, reproduced and drafted with AI assistance (Claude Code)
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Output from running 'python -VV' on the command line:
Python 3.16.0a0 (heads/main:a1d580430c8, Jul 18 2026, 20:21:17) [Clang 21.1.8 (6ubuntu1)]
Crash report
What happened?
Doc/c-api/dict.rsttells aPyDict_WatchCallbacktwo things:A callback that does exactly the second thing causes CPython to do exactly the first thing on its behalf.
_PyDict_SendEvent(Objects/dictobject.c:8309-8317):PyErr_FormatUnraisablerunssys.unraisablehook, which is settable from pure Python. So the "do not trigger Python code execution" requirement is discharged by the callback and then violated by the runtime — inside the notify window, while the dict is mid-update.The docs do say the exception becomes an unraisable. What they don't say is that this happens synchronously, inside the operation, at a point where the calling code has already captured an entry index, an entry pointer, a bound, or a borrowed reference that it will use afterwards.
Thirteen of the fourteen notify sites hold stale state across that window
I drove all fourteen. Thirteen hold state across the notify and all thirteen produce an observable failure; twelve of those are memory corruption or a memory-invariant assertion. One site is safe.
:1917insert_combined_dictdk_usablebound:1997_PyDict_InsertSplitValueix, split-table-ness:1998,ma_values == NULL— 5/5 on each of four builds:2003_PyDict_InsertSplitValueix,old_value:20045/5 on four builds; embedded variant asserts inclear_freelist:2060insertdictix,old_value:2076; heap-buffer-overflow WRITE at:2068;_Py_NegativeRefcount5/5:2103insert_to_emptydictnewkeysma_useddesync puts a NULL in alist→ SIGSEGV inlist_sort_impl:3038_PyDict_DelItem_KnownHash_LockHeldixassert(hashpos >= 0); releasePy_DECREF(NULL):3083delitemif_lock_heldix,old_value,hashPy_DECREF(NULL);len()→ NULL 5/5 release:3142clear_lock_helddictkeys_decref:496:3307_PyDict_Pop_KnownHashix,old_value:3308; SIGSEGV 5/5 release:4234dict_dict_mergeclone_combined_dict_keys; silent data loss 5/5 — no memory corruption reached:5051dict_popitem_impl(unicode)ep0popitem()returns a tuple whose[1]is a raw C NULL:5066dict_popitem_impl(general)ep0,i:5067:7510store_instance_attr_lock_heldvalues,dict,ix,old_valueassert(i < size); ASan overflow WRITE + UAF READ;len(),list()andvars()all raise:3652dict_deallocma_values/ma_keysat:3656-3657, after the notifyAlso reached from these paths:
delete_index_from_values:2943, whose search loop is bounded only byassert(i < size)— debug SIGABRT, and on plain release it silently loses a dict entry.Four that are worth calling out individually
:1997/:2003are in_PyDict_InsertSplitValue, which isPyAPI_FUNCand carries the comment// Exported for external JIT support. The hook that kills it isd.clear()afterdel objhas detached the dict, which makesclear_lock_heldtake itsset_values(mp, NULL)branch; the split-table store then dereferences NULL. gdb confirmsma_values == 0x0,ma_keys == &empty_keys_struct,ix == 2.:3083breaks a promise the file makes in a comment.dictobject.c:3090-3094tells callers the predicate → deletion sequence is atomic provided the predicate does not re-enter.is_dead_weakrefdoes not re-enter; the notify does. Reachable as_weakref._remove_dead_weakref(dct, key).:3307hands freed memory back to Python.*resultat:3312is the dict's own reference, returned straight to thedict.pop()caller after the hook dropped it.:7510gives the sharpest Python-visible invariant break.delete_index_from_valuesdoessize--on a size the hook zeroed, andvalues->sizeis auint8_t, so it wraps to 255 while:7527drivesma_usedto-1. After a plaindel o.attr, onrelease-gil-nojit, 5/5:The one I could not corrupt
:4234(dict_dict_merge,PyDict_EVENT_CLONED) holds every guard established at:4219and:4228-4232across the notify, and those guards are genuinely unchecked afterwards — but I could not reach memory corruption in roughly nine hook variants (0/3 under ASan). The two guards whose falsification would give a type-confused dict are out of reach from Python:mpis a plain dict so a hook cannot makemp->ma_valuesnon-NULL, and a combinedothercannot be turned back into a split table. What it does give is a 5/5 debug assertion violation inclone_combined_dict_keysand 5/5 silent data loss on all four builds —d.update(src)discards everything the hook wrote tod, with no error. On release, that path also leavesdowning a heap keys object carrying_Py_DICT_IMMORTAL_INITIAL_REFCNT(memcpy'd fromempty_keys_struct), which can never be freed and permanently falsifiesclear_lock_held'sassert(oldkeys->dk_refcnt == 1)at:3148.Reproducer
The callback is CPython's own —
dict_watch_callback_errorinModules/_testcapi/watchers.c:90-98, in its entirety:That is the documented protocol and nothing else: it sets an exception, returns
-1, and runs no Python. The re-entry comes entirely from CPython's response to it.One per-site reproducer for each of the thirteen is attached, along with negative controls — for
:2060, hooks that store immortal small ints or force a resize do not crash it, so the claim is not "any hook corrupts anything". That site needs heap values specifically: with small ints the stalePy_XDECREF(old_value)at:2076is a no-op because they are immortal, which is why an earlier attempt at this site came back clean.Registering a watcher requires the C API, so this is not reachable from pure Python alone — but
sys.unraisablehookis pure Python, and any extension that installs a conforming watcher hands that reach to arbitrary Python code.The one safe site, and why
dict_dealloc(:3650-3658) is the only notify site that survives this. It brackets the notify in_PyObject_ResurrectStart/End, but that bracket defends against resurrection — the comment at_PyDict_SendEvent:8309-8312names that threat and only that threat. Its mutation-safety is incidental: it re-readsma_keys/ma_valuesafter the notify rather than before.So the thing to propagate is the ordering, not the bracket.
Notes toward a fix
Three directions, not mutually exclusive:
format_unraisable_v(Python/errors.c:1737), which is the single choke point every unraisable site funnels through.Given that thirteen of fourteen sites are affected, the central options look more attractive than the per-site one.
Objects/typeobject.c:1219already carries the observation, added in gh-127266:dictobject.chas no equivalent, and its notify sites were never audited against it.Related
dict_watch_callback(watchers.c:31-70), violates both halves of the documented contract:PyUnicode_FromFormat("new:%S:%S", ...)callsPyObject_Str(forbidden bydict.rst:583-584), and it callsPyUnicode_FromFormat+PyList_Appendwith no save/restore (required bydict.rst:599-603). If the example cannot follow the contract, it is worth asking whether the contract is stateable.PyType_WatchCallback(Doc/c-api/type.rst:138-155) documents no error protocol at all, yettypeobject.c:1222treats< 0as "exception set" and reports it the same way.Found with [cpython-review-toolkit]](https://github.com/devdanzin/cpython-review-toolkit); reduced, reproduced and drafted with AI assistance (Claude Code)
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Output from running 'python -VV' on the command line:
Python 3.16.0a0 (heads/main:a1d580430c8, Jul 18 2026, 20:21:17) [Clang 21.1.8 (6ubuntu1)]