guppy3 v3.1.6 (~14.5K lines of C) is a heap profiler for CPython. It necessarily accesses CPython internals (frame objects, GC lists, interpreter/thread state structures) and maintains version-conditional code paths for every CPython release from 3.10 through 3.14. The extension uses pythoncapi_compat.h and has excellent version compatibility engineering with zero unguarded new API usages.
However, the analysis uncovered significant bugs including a wrong-variable NULL check (classic typo), a deref-before-check in the bitset allocator, an unchecked PyObject_Realloc, a potential infinite loop on Python 3.14, and the xt_error sentinel pattern that silently loses error context across ~15 call sites. The set_relate function has a triple bug (iterator leak + obj leak + unchecked NULL to callback) on every successful set membership check.
Total confirmed findings: 30 FIX, 30 CONSIDER, 6 POLICY.
All 13 agents completed.
Project: guppy3 — CPython heap profiler
Source: src/heapy/ (~7.9K lines) + src/sets/ (~5.8K lines)
Version analyzed: 3.1.6
Agents completed: 13 + clang-tidy
| # | Finding | File | Agents |
|---|---|---|---|
| 1 | ng_iter checks wrong variable — !v instead of !iter after PyObject_GC_New. Crashes on OOM during iter(nodegraph). |
nodegraph.c:581-584 |
1 agent |
| 2 | mutbitset_initset deref before check — dereferences PyObject_Realloc result before NULL check. Check is dead code. |
bitset.c:793-796 |
1 agent |
| 3 | immbitset_realloc unchecked PyObject_Realloc — dereferences result through PyObject_InitVar. Also corrupts object tracking via prior _Py_ForgetReference. |
bitset.c:960-962 |
1 agent |
| 4 | hv_cli_findex only checks one of two PyTuple_New results — s->cmps NULL flows to PyTuple_SET_ITEM(NULL, ...) |
hv_cli_findex.c:125-128,144 |
2 agents |
| 5 | sf_slice unchecked NyImmBitSet_New at two points — bitset slicing crashes on OOM |
bitset.c:2958,2998 |
1 agent |
| 6 | _hiding_tag__name unchecked after PyUnicode_FromString — NULL stored in global, later passed to PyDict_GetItem in every heap traversal |
heapyc.c:253 |
3 agents |
| 7 | frame_traverse infinite loop on Python 3.14 — for (p = ...; p < ...; i++) increments i but loop variable is p |
stdtypes.c:452 |
1 agent |
| # | Finding | File | Agents |
|---|---|---|---|
| 25 | NyHeapView tp_traverse missing _hiding_tag_ visit — GC cannot see through this owned PyObject* member. tp_clear correctly clears it. |
hv.c:124-141 |
1 agent |
| 26 | NyNodeTuple_Type missing tp_dealloc — items stored via PyTuple_SET_ITEM never decref'd. Leaks refs on every NodeTuple destruction. Used as dict keys in classifier memoization. |
hv_cli_and.c:266-278 |
1 agent |
| # | Finding | File | Agents |
|---|---|---|---|
| 24 | ObjectClassifier() segfaults — exported type, NYFILL macro assigns PyType_GenericNew, all methods dereference NULL def pointer. Directly callable from Python. |
classifier.c:321, heapyc.c:245 |
1 agent |
| # | Finding | File | Agents |
|---|---|---|---|
| 8 | set_relate triple bug — iterator it leaked, matched obj leaked, unchecked PyLong_FromSsize_t NULL passed to r->visit. On every successful set membership check. |
stdtypes.c:157-181 |
2 agents |
| 9 | hv_extra_type converts allocation failure to silent xt_error sentinel — loses error context, causes cascade of return NULL without exception across ~15 call sites |
hv.c:462-464 |
1 agent |
| 10 | hv_relate returns NULL without exception via xt_error cascade |
hv.c:1295-1318 |
1 agent |
| 11 | Unchecked PyUnicode_FromString passed to r->visit in xt_relate — NULL relator depends on defensive visitor code |
hv.c:514 |
1 agent |
| # | Finding | File | Agents |
|---|---|---|---|
| 14 | cli_partition_iter over-decrements borrowed/released sp on error — Py_XDECREF(sp) in error label on a ref the function doesn't own. Double-free / refcount underflow. |
classifier.c:94-120 |
1 agent |
| 15 | outrel_visit borrowed ref used after Py_DECREF — PyDict_GetItem borrowed ref or decref'd new relation passed to NyNodeSet_setobj |
hv_cli_rel.c:273-284 |
1 agent |
| 16 | hv_cli_prod_le borrowed refs across arbitrary Python comparisons — PyTuple_GetItem borrowed refs used across PyObject_RichCompareBool which runs __eq__/__le__ |
hv_cli_prod.c:142-194 |
1 agent |
| 17 | dict_relate_kv passes unchecked NULL to r->visit — PyLong_FromSsize_t OOM silently swallowed by visitor. Same pattern across ~25 call sites. |
stdtypes.c:62-65 |
1 agent |
| 18 | hv_PyList_Pop leaks reference when PyList_SetSlice fails — Py_XINCREF'd item not decref'd on error. Affects 5 heap traversal call sites. |
hv.c:107-111 |
1 agent |
| 19 | mutnsiter_iternext leaks bitobj on conversion error — PyLong_AsSsize_t error path missing Py_DECREF(bitobj) |
nodeset.c:232-234 |
1 agent |
| # | Finding | File | Agents |
|---|---|---|---|
| 20 | immbitset_realloc + union_realloc — _Py_ForgetReference + NULL PyObject_Realloc = memory leak + object list corruption — 2 sites with identical pattern |
bitset.c:957-963,900-906 |
2 agents |
| 21 | NyNodeGraph_AddEdge — PyMem_RESIZE overwrites ng->edges with NULL — old buffer with incref'd PyObject* permanently lost |
nodegraph.c:169-176 |
1 agent |
| 22 | NyImmBitSet_SubtypeNewArg unchecked alloc before memcpy — NULL deref in ImmBitSet constructor |
bitset.c:726-727 |
1 agent |
| 23 | sf_slice unchecked NyImmBitSet_New at 2 points — bitset slicing crashes on OOM |
bitset.c:2958,2998 |
2 agents |
| # | Finding | File | Agents |
|---|---|---|---|
| 12 | hv_cli_indisize_memoized_kind missing PyErr_Occurred check — the ONE outlier among 6 structurally identical memoized_kind functions |
hv_cli_indisize.c:14 |
2 agents |
| 13 | cli_partition_iter missing PyErr_Occurred check — user-defined classifier keys with arbitrary __hash__ go through PyDict_GetItem. Highest risk: kind is arbitrary user code. |
classifier.c:102 |
2 agents |
- Unguarded
PyErr_Clearinlazy_init_hv_cli_prod(hv_cli_prod.c:62) outrel_visitmissingPyErr_OccurredafterPyDict_GetItem(hv_cli_rel.c:273)- 3 hiding-tag checks use
PyDict_GetItemfor pointer comparison (benign but imprecise)
- 7 unchecked
PyDict_SetItemStringin heapyc init (heapyc.c:241-252) - 5 unchecked
PyDict_SetItemStringin bitset init (bitset.c:4464-4474) NyStdTypes_initreturns void, ignores allocation failures (stdtypes.c:800-830)- Unchecked
PyCapsule_NewNULL passed toPyDict_SetItemString(bitset.c:4468)
r->visitreturn value ignored innodeset_relate_visit(nodeset.c:432)nodegraph_relatereturns 0 instead of propagating visit errors (nodegraph.c:760-775)- Unchecked
PyLong_FromSsize_tpassed to callbacks (multiple sites)
- 13
PyDict_GetItemcalls — should migrate toPyDict_GetItemWithError PySys_GetObjectdeprecated in 3.13 (hv_cli_prod.c:34)- Outdated vendored
pythoncapi_compat.h
hv_numedgesreturn 0 may lack exception viaxt_errorcascade (hv.c:1047)PySequence_Length-1 return treated as truthy (hv_cli_prod.c:121)- Dead Python 2 file
initheapyc.c
| Aspect | Status |
|---|---|
| Init style | Single-phase, both modules. m_size = -1. |
| Stable ABI | Not feasible. 8 CPython internal headers, 9 private API functions, 65+ direct struct accesses. Inherent to heap profiler design. |
| Static types | 19 (8 heapy + 11 sets) with inheritance chains |
| GIL release | Never — correct for heap profiler accessing GC internals |
| Free-threading | Fundamentally incompatible (Horizon dealloc patching, RootState singleton, interpreter state traversal) |
| PyErr_Clear | Only 2 explicit calls (1 guarded, 1 unguarded). 5 unguarded PyDict_GetItem implicit clears. |
| Complexity | No hotspots above 5.0. rootstate_getattr at 4.9 (nesting depth 9). |
| pythoncapi_compat | Used but outdated — missing newer backports |
| Version compat | Excellent — version-conditional code for 3.10-3.14, zero unguarded new APIs |
-
Finding 7 (infinite loop on 3.14) —
for (p = ...; p < ...; i++)should bep++. One-character fix. Hangs during GC traversal on Python 3.14. -
Finding 1 (
ng_iterwrong variable) —!vshould be!iter. One-character fix. Crashes on OOM during iteration. -
Finding 8 (
set_relatetriple bug) — AddPy_DECREF(it),Py_DECREF(obj), and checkPyLong_FromSsize_treturn. Leaks on every successful set membership check. -
Finding 9 (
xt_errorsentinel) — AddPyErr_SetStringtoxt_error_traverseandxt_error_relate. Fixes the root cause of ~15return NULLwithout exception paths. -
Finding 2 (
mutbitset_initsetderef before check) — Move NULL check before dereference. One-line reorder. -
Finding 14 (
cli_partition_iterover-decrement) —Py_XDECREF(sp)on borrowed/released ref = refcount underflow. Corrupts partition dict list objects. -
Finding 20 (
_Py_ForgetReference+ NULL realloc) — memory leak + object list corruption on OOM in bitset resize. Fix by re-tracking original on failure. -
Finding 21 (
PyMem_RESIZEoverwritesng->edges) — UsePyMem_Reallocinto a temp variable instead. The macro assigns NULL to the first arg on failure.
Found using cext-review-toolkit (11 of 13 agents completed, v0.1.5).