[Bug]: Umbrella issue: matplotlib C/C++ Extension Analysis Report #31424
Description
Bug summary
This is an umbrella issue meant to present the findings of an analysis report so matplotlib developers and contributors can discuss them, figure out which are worth fixing, open individual issues and PRs, etc.
Findings by Priority
FIX
-
std::stringconstructed from NULLft_error_string—ft_error_string()returns NULL for unknown FreeType error codes.THROW_FT_ERRORconstructsstd::string{nullptr}— undefined behavior (typically crashes instrlen). Triggered when FreeType returns an error code not in the compiled-in list.ft2font.h:39,52
-
Inverted
PyErr_Occurredlogic in enum type caster —return !(ival == -1 && !PyErr_Occurred())returnstrue(success) whenPyLong_AsLongfails with an active exception. One-character fix: the!beforePyErr_Occurred()should be removed._enums.h:83
-
Unchecked
malloc+ leakedPy_bufferin_copy_agg_buffer—malloc(sizeof(Py_buffer))not NULL-checked. IfPyObject_GetBufferfails, the buffer is leaked._macosx.m:1221-1225
-
Unchecked
PyOS_double_to_string—strlen(NULL)crash —PyOS_double_to_string()can return NULL on OOM.strlen(str)follows without a NULL check. Crash confirmed under OOM injection._path.h:1070-1073
-
FigureCanvas_set_cursorreturns NULL without exception — Thedefault:case returns NULL without callingPyErr_Set*, causingSystemError._macosx.m:451
-
FigureManager__set_window_modereturns NULL without exception — Whenself->windowis NULL (afterdestroy()), returns NULL without setting an exception._macosx.m:653-654
-
NULL
char*streamed tostd::stringstreaminft_glyph_warn—face->family_namecan be NULL. When NULL is inserted intostd::set<FT_String*>and later streamed viass << *it, it's undefined behavior.ft2font.cpp:457,ft2font_wrapper.cpp:410
-
NSFileHandleleak inwake_on_fd_write—[[NSFileHandle alloc] initWithFileDescriptor: fd]is never released._macosx.m:246-257
CONSIDER
-
GIL not released during Agg rendering —
draw_path,draw_markers,draw_path_collection, etc. hold the GIL during software rasterization, blocking all threads._image_wrapper.cppalready demonstrates the correct GIL-release pattern._backend_agg_wrapper.cpp:41-215
-
GIL not released during Qhull triangulation — 500K-point triangulation holds the GIL for 3+ seconds, reducing background thread throughput to 0.6% of baseline.
_qhull_wrapper.cpp:138-253
-
Global
FT_Librarynot thread-safe —_ft2Libraryis shared across all threads. FreeType is not thread-safe for shared library instances.ft2font.cpp:44
-
FreeType stream read callback lacks GIL guard for free-threading — Calls Python I/O without GIL guard on free-threaded builds.
ft2font_wrapper.cpp:367-388
-
ft_glyph_warncalls Python APIs without GIL guard for free-threading — Module import/attr access without GIL on free-threaded builds.ft2font_wrapper.cpp:405-418
-
Global
p11x::enumsmap lacks synchronization —std::unordered_mapwithpy::objectvalues, no synchronization for free-threading._enums.h:37
-
NSTrackingArealeak inFigureCanvas_init—allocwithout matchingrelease._macosx.m:378-382
-
Window.closedouble-decref risk —Py_DECREF(manager)ifdeallocruns without priordestroy._macosx.m:1177-1188
-
PyFT2Font_initleaks on exception — Rawnewwithoutunique_ptr. ~59 bytes leaked per failed font construction (corrupt font file), ~131 bytes per failed construction (failing file-like object).ft2font_wrapper.cpp:457-505
-
Timer NSTimer captures raw
selfpointer — Block captures Python object withoutPy_INCREF._macosx.m:1807-1816
-
View stores raw
canvaspointer — NoPy_INCREF, potential dangling pointer._macosx.m:139
-
Exception clobbering in ft2font catch-all —
catch(std::exception&)replaces meaningful errors (includingMemoryError,KeyboardInterrupt) with genericTypeError.ft2font_wrapper.cpp:492-494
-
Unchecked
PyUnicode_EncodeFSDefault— NULL wrapped inpy::bytes._tkagg.cpp:333-335
-
PyErr_Fetch/PyErr_Restoredeprecated since 3.12 — Should migrate toPyErr_GetRaisedException/PyErr_SetRaisedException.ft2font_wrapper.cpp:394,402
-
PY_SSIZE_T_CLEANdefines (×3) — No-op since 3.10, can be removed.mplutils.h,py_adaptors.h,_macosx.m
-
Solaris
_XPG4/_XPG3undefs — Likely dead code.mplutils.h:22-28
-
macOS SDK < 10.14 compat defines — Likely dead code.
_macosx.m:11-16
Code for reproduction
See https://gist.github.com/devdanzin/2a79188c7a41a03b1476544c7dd775c1#matplotlib-c-extension--reproducer-appendix for reproducers.
Actual outcome
Expected outcome
Additional information
The issue above is part of an analysis report created by cext-review-toolkit, a Claude Code plugin for reviewing CPython C extensions. It covers the C/C++ source in src/ (13 source files, 9 extension modules — 8 pybind11 + 1 raw Python/C API in _macosx.m).
Some findings may be false positives or low-priority issues not worth fixing. Feedback on any misclassified findings is welcome.
The full report (including a reproducers appendix) is available at https://gist.github.com/devdanzin/2a79188c7a41a03b1476544c7dd775c1.
Operating system
Linux
Matplotlib Version
main
Matplotlib Backend
No response
Python version
No response
Jupyter version
No response
Installation
git checkout
Activity
scottshambaugh commentedon Mar 30, 2026
Paraphrasing my comment from discourse:
Thanks for running this!
It looks like most of these are low impact but also quick to implement, and a single PR could target several in a category at once. I don't see anything urgent to address at the moment, but the discussion here will help with visibility and keeping track of the conversation. We can always pick-and-choose as needed.
Some are not worth doing - my take is that that OOM / pybind11 issues are fundamental architecture limitations and not really something for us to address, the GIL release items are potentially tricky to put boundaries around and of limited use for this as a primarily single-threaded tool, and the free-threading concerns are premature since we need to support legacy python versions for quite a while.
I think we on the maintainers team all knew automated code auditing tools like this were coming, but haven't figured out how we're going to handle them / how much stock to put in them. This is one of the better executed audits I've seen so far, and I think will be a useful first case for us to talk through some of those considerations.
tacaswell commentedon Apr 2, 2026
Skimming this, I think one PR to handle the macos.m issues all together would make sense.
I suspect that the freetype issues are going to be at least different very soon once the font refactor PR lands.
Adding null checks is non-controversial and I don't see problems with those coming in in batches.
kx7m2qd commentedon May 4, 2026
I've opened a PR to address the
PyErr_Fetch/PyErr_Restoredeprecation itemfrom this list: #[your PR number]
The fix updates
close_file_callbackinsrc/ft2font_wrapper.cppto usePyErr_GetRaisedException/PyErr_SetRaisedExceptionon Python 3.12+, guardedby
#if PY_VERSION_HEX >= 0x030c0000for backward compatibility.Fix deprecated PyErr_Fetch/PyErr_Restore in close_file_callback
Fix deprecated PyErr_Fetch/PyErr_Restore in close_file_callback
clin1234 commentedon Jul 19, 2026
Updated report, as of d7bc494:
Findings by Priority
Global, non-restarting numbering. "Agents" notes cross-validation.
Must Fix (FIX) — 2
PyFT2Font_initis a pybind11 factory thatnews aPyFT2Font(line 521) but can throw before returning it, so pybind11 never adopts the pointer and it leaks — on every failed construction. Reachable triggers:io.openfails on a bad path (:546), non-binary file-like arg (:556), orFT_Open_Facerejects a corrupt/truncated/unsupported font (:563viaFT_CHECK). On the:563path an already-open file object (py_file) is stranded too (memory always; fd on the invalid-font path). Fix: hold it instd::make_unique<PyFT2Font>(...)andreturn self.release();only on success (unwind is then clean —~PyFT2Font→close()no-ops onface==nullptr).ft2font_wrapper.cpp:521-563(registered:1589)FT2Imageconstructor callscalloc(width * height, 1)with no NULL check, andwidth * height(bothunsigned long) can integer-overflow → under-allocation then out-of-bounds writes indraw_bitmap/draw_rect_filled, or a NULL-deref on allocation failure. Fix: overflow-check the product and verify the result, throwing instead of returning a NULL buffer.ft2font.cpp:21-24(writes:50-64,:79-83)calloc)Should Consider (CONSIDER) — 11
_ft2Library(non-static, so the module-state scanner missed it): everyFT_Open_Face/FT_Done_Facemutates it unlocked → a data race undermod_gil_not_used()and a subinterpreter blocker. Full analysis + fix inFREETHREADING_ANALYSIS.md(Finding 1, CRITICAL).ft2font.cpp:19, 200, 223RendererAggconstructor leakspixBuffer(andalphaBuffer) if a laternew[]throws (bad_alloc, reachable via a pathological largedpi→hatch_size²). A throwing ctor means~RendererAggnever runs. Fix: hold the buffers instd::vector/unique_ptr. (OOM-only → LOW.)_backend_agg.cpp:44, 52, 66FT_Glyphhandle leak betweenFT_Get_Glyphandglyphs.push_back— if thepush_back(or an earlierxys.push_back) throwsbad_alloc, the in-flightthisGlyphis neverFT_Done_Glyph'd. Fix: RAII-guardthisGlyph, orglyphs.reserve()before the loop. (OOM-only → LOW.)ft2font.cpp:482→506,558→559tp_traverse/tp_clearfor C++-memberpy::objects, so a cycle throughFT2Font.py_file/fallbacks/image(or the persistent numpy arrays in_tritypes) leaks the whole cycle. Lifetime itself is safe (thepy::list fallbacksowns the children; the rawFT2Font*/char_to_fontare borrowed views — no use-after-free). Inherent pybind11 limitation (pybind/pybind11#1029).ft2font_wrapper.cpp:359-361;ft2font.h:185,189-190;tri/_tri.h:306-316.pyistub drift. (a) Wrong types that break type-checkers/keyword use:_path.count_bboxes_overlapping_bboxis typedSequence[BboxBase]but the C++ takes an ndarray (real caller passesnp.atleast_3d(...));_tri.Triangulation.__init__paramcorrect_triangle_orientationvs C++...orientations;FT2Font.draw_glyph(s)_to_bitmap/_layout/load_glyphhave kw-only / renamed params vs the stub; Win32 HWND capsules typed asint. (b) Empty/partial stubs (ship despitepy.typed):_qhull.pyi,_image.pyi,backends/_backend_agg.pyi,backends/_macosx.pyiare 0 bytes;_path.pyitypes only 2 of 14 functions. Runtime-safe, but degrades the advertised typed API._path.pyi:8;_tri.pyi:31;ft2font.pyi:245-291;_qhull.pyi/_image.pyi/backends/_backend_agg.pyi/backends/_macosx.pyi(empty)_tkaggcaches Tk/Tcl entry points in file-scope statics viadlsym;ft2fonthas a staticFT_Outline_Funcstable. Safe for free-threading (write-once) but process-global — must move to per-module state for PEP 489 / subinterpreter support._tkagg.cpp:90,91,94,95;ft2font.cpp:149_wrapper.cppPYBIND11_MODULElinesQhullInfomanages resources (qhT*,FILE*) but declares no copy-constructor /operator=(Rule of Three). Never copied today, so no live bug, but an accidental copy would double-fclose/double-free. Recommend= deleteon both. (Destructor itself is correct — see Strengths.)_qhull_wrapper.cpp:112-136appidCoTaskMemFreeleak ifpy::castthrows before the free (Windows-only, one-shot, OOM). Move the free ahead of the cast or use a scope guard._c_internal_utils.cpp:108-109LayoutItem::ft_objectis a borrowedPyFT2Font*exposed viadef_readonlywith no keep-alive — aLayoutItemoutliving its font would dangle. Transient, private (_layout) API → safe today; guard only ifLayoutItems are ever stored.ft2font_wrapper.cpp:1382, 1566mod_gil_not_used()asserted before it is enforced — nostd::mutex/py::scoped_critical_sectionanywhere insrc/. Until Finding 3 is guarded, the thread-safety claim on all 8 modules is unsound.Tensions
mod_gil_not_used()vs. current reality (Finding 13): the C-level thread-safety claim is ahead of the C-level guarantees; the Python-layer thread-keyed font cache only partially compensates.py::list+ borrowedFT2Font*) is correct for lifetime but is exactly what makes cycles GC-invisible; fixing GC-visibility would mean custom type slots, adding complexity this otherwise-clean code avoids.Source: https://gist.github.com/clin1234/2469758160721f4f991110167017e4d3#file-matplotlib-md