Skip to content

[Bug]: Umbrella issue: matplotlib C/C++ Extension Analysis Report #31424

Description

@devdanzin

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

  1. std::string constructed from NULL ft_error_string — ft_error_string() returns NULL for unknown FreeType error codes. THROW_FT_ERROR constructs std::string{nullptr} — undefined behavior (typically crashes in strlen). Triggered when FreeType returns an error code not in the compiled-in list.

    • ft2font.h:39,52
  2. Inverted PyErr_Occurred logic in enum type caster — return !(ival == -1 && !PyErr_Occurred()) returns true (success) when PyLong_AsLong fails with an active exception. One-character fix: the ! before PyErr_Occurred() should be removed.

    • _enums.h:83
  3. Unchecked malloc + leaked Py_buffer in _copy_agg_buffer — malloc(sizeof(Py_buffer)) not NULL-checked. If PyObject_GetBuffer fails, the buffer is leaked.

    • _macosx.m:1221-1225
  4. 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
  5. FigureCanvas_set_cursor returns NULL without exception — The default: case returns NULL without calling PyErr_Set*, causing SystemError.

    • _macosx.m:451
  6. FigureManager__set_window_mode returns NULL without exception — When self->window is NULL (after destroy()), returns NULL without setting an exception.

    • _macosx.m:653-654
  7. NULL char* streamed to std::stringstream in ft_glyph_warn — face->family_name can be NULL. When NULL is inserted into std::set<FT_String*> and later streamed via ss << *it, it's undefined behavior.

    • ft2font.cpp:457, ft2font_wrapper.cpp:410
  8. NSFileHandle leak in wake_on_fd_write — [[NSFileHandle alloc] initWithFileDescriptor: fd] is never released.

    • _macosx.m:246-257

CONSIDER

  1. 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.cpp already demonstrates the correct GIL-release pattern.

    • _backend_agg_wrapper.cpp:41-215
  2. 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
  3. Global FT_Library not thread-safe — _ft2Library is shared across all threads. FreeType is not thread-safe for shared library instances.

    • ft2font.cpp:44
  4. 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
  5. ft_glyph_warn calls Python APIs without GIL guard for free-threading — Module import/attr access without GIL on free-threaded builds.

    • ft2font_wrapper.cpp:405-418
  6. Global p11x::enums map lacks synchronization — std::unordered_map with py::object values, no synchronization for free-threading.

    • _enums.h:37
  7. NSTrackingArea leak in FigureCanvas_init — alloc without matching release.

    • _macosx.m:378-382
  8. Window.close double-decref risk — Py_DECREF(manager) if dealloc runs without prior destroy.

    • _macosx.m:1177-1188
  9. PyFT2Font_init leaks on exception — Raw new without unique_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
  10. Timer NSTimer captures raw self pointer — Block captures Python object without Py_INCREF.

    • _macosx.m:1807-1816
  11. View stores raw canvas pointer — No Py_INCREF, potential dangling pointer.

    • _macosx.m:139
  12. Exception clobbering in ft2font catch-all — catch(std::exception&) replaces meaningful errors (including MemoryError, KeyboardInterrupt) with generic TypeError.

    • ft2font_wrapper.cpp:492-494
  13. Unchecked PyUnicode_EncodeFSDefault — NULL wrapped in py::bytes.

    • _tkagg.cpp:333-335
  14. PyErr_Fetch/PyErr_Restore deprecated since 3.12 — Should migrate to PyErr_GetRaisedException/PyErr_SetRaisedException.

    • ft2font_wrapper.cpp:394,402
  15. PY_SSIZE_T_CLEAN defines (×3) — No-op since 3.10, can be removed.

    • mplutils.h, py_adaptors.h, _macosx.m
  16. Solaris _XPG4/_XPG3 undefs — Likely dead code.

    • mplutils.h:22-28
  17. 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

  1. scottshambaugh commented on Mar 30, 2026

    @scottshambaugh
    Contributor

    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.

  2. tacaswell commented on Apr 2, 2026

    @tacaswell
    Member

    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.

  3. kx7m2qd commented on May 4, 2026

    @kx7m2qd

    I've opened a PR to address the PyErr_Fetch/PyErr_Restore deprecation item
    from this list: #[your PR number]

    The fix updates close_file_callback in src/ft2font_wrapper.cpp to use
    PyErr_GetRaisedException/PyErr_SetRaisedException on Python 3.12+, guarded
    by #if PY_VERSION_HEX >= 0x030c0000 for backward compatibility.

  4. clin1234 commented on Jul 19, 2026

    @clin1234

    Updated report, as of d7bc494:

    Findings by Priority

    Global, non-restarting numbering. "Agents" notes cross-validation.

    Must Fix (FIX) — 2

    # Finding File:Line Agents
    1 PyFT2Font_init is a pybind11 factory that news a PyFT2Font (line 521) but can throw before returning it, so pybind11 never adopts the pointer and it leaks — on every failed construction. Reachable triggers: io.open fails on a bad path (:546), non-binary file-like arg (:556), or FT_Open_Face rejects a corrupt/truncated/unsupported font (:563 via FT_CHECK). On the :563 path an already-open file object (py_file) is stranded too (memory always; fd on the invalid-font path). Fix: hold it in std::make_unique<PyFT2Font>(...) and return self.release(); only on success (unwind is then clean — ~PyFT2Font→close() no-ops on face==nullptr). ft2font_wrapper.cpp:521-563 (registered :1589) type-slot-checker + resource-lifecycle-checker (independent cross-validation)
    2 FT2Image constructor calls calloc(width * height, 1) with no NULL check, and width * height (both unsigned long) can integer-overflow → under-allocation then out-of-bounds writes in draw_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) null-safety (manual — scanner missed the C++ member-init calloc)

    Should Consider (CONSIDER) — 11

    # Finding File:Line
    3 Shared global FreeType library _ft2Library (non-static, so the module-state scanner missed it): every FT_Open_Face/FT_Done_Face mutates it unlocked → a data race under mod_gil_not_used() and a subinterpreter blocker. Full analysis + fix in FREETHREADING_ANALYSIS.md (Finding 1, CRITICAL). ft2font.cpp:19, 200, 223
    4 RendererAgg constructor leaks pixBuffer (and alphaBuffer) if a later new[] throws (bad_alloc, reachable via a pathological large dpi → hatch_size²). A throwing ctor means ~RendererAgg never runs. Fix: hold the buffers in std::vector/unique_ptr. (OOM-only → LOW.) _backend_agg.cpp:44, 52, 66
    5 FT_Glyph handle leak between FT_Get_Glyph and glyphs.push_back — if the push_back (or an earlier xys.push_back) throws bad_alloc, the in-flight thisGlyph is never FT_Done_Glyph'd. Fix: RAII-guard thisGlyph, or glyphs.reserve() before the loop. (OOM-only → LOW.) ft2font.cpp:482→506, 558→559
    6 Reference cycles through Python objects stored as C++ members are invisible to the cyclic GC. pybind11 installs no tp_traverse/tp_clear for C++-member py::objects, so a cycle through FT2Font.py_file/fallbacks/image (or the persistent numpy arrays in _tri types) leaks the whole cycle. Lifetime itself is safe (the py::list fallbacks owns the children; the raw FT2Font*/char_to_font are 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
    7 .pyi stub drift. (a) Wrong types that break type-checkers/keyword use: _path.count_bboxes_overlapping_bbox is typed Sequence[BboxBase] but the C++ takes an ndarray (real caller passes np.atleast_3d(...)); _tri.Triangulation.__init__ param correct_triangle_orientation vs C++ ...orientations; FT2Font.draw_glyph(s)_to_bitmap/_layout/load_glyph have kw-only / renamed params vs the stub; Win32 HWND capsules typed as int. (b) Empty/partial stubs (ship despite py.typed): _qhull.pyi, _image.pyi, backends/_backend_agg.pyi, backends/_macosx.pyi are 0 bytes; _path.pyi types 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)
    8 Write-once static mutable state (subinterpreter blockers). _tkagg caches Tk/Tcl entry points in file-scope statics via dlsym; ft2font has a static FT_Outline_Funcs table. 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
    9 Single-phase init across all modules — combined with the globals above, blocks subinterpreter support. all _wrapper.cpp PYBIND11_MODULE lines
    10 QhullInfo manages 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 = delete on both. (Destructor itself is correct — see Strengths.) _qhull_wrapper.cpp:112-136
    11 appid CoTaskMemFree leak if py::cast throws 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-109
    12 LayoutItem::ft_object is a borrowed PyFT2Font* exposed via def_readonly with no keep-alive — a LayoutItem outliving its font would dangle. Transient, private (_layout) API → safe today; guard only if LayoutItems are ever stored. ft2font_wrapper.cpp:1382, 1566
    13 mod_gil_not_used() asserted before it is enforced — no std::mutex/py::scoped_critical_section anywhere in src/. Until Finding 3 is guarded, the thread-safety claim on all 8 modules is unsound. project-wide

    Tensions

    • Subinterpreters vs. simplicity (Findings 8–9): module-state-checker flags single-phase init and process-global state, but the codebase is small and low-complexity and there is no stated subinterpreter goal. Free-threading (the active effort) does not require multi-phase init — only synchronization of the shared state.
    • 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.
    • Ownership vs. GC-visibility (Finding 6): the double bookkeeping of fallbacks (owning py::list + borrowed FT2Font*) 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions