From cdea52e7641eb565f18bed2e8093eac442e2c5e8 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 14:28:37 -0400 Subject: [PATCH 01/11] Annotate pyi files to better match C++ interfaces --- lib/matplotlib/_image.pyi | 60 +++++++++++++ lib/matplotlib/_path.pyi | 3 +- lib/matplotlib/_qhull.pyi | 9 ++ lib/matplotlib/_tri.pyi | 2 +- lib/matplotlib/backends/_backend_agg.pyi | 103 +++++++++++++++++++++++ lib/matplotlib/backends/_macosx.pyi | 38 +++++++++ lib/matplotlib/ft2font.pyi | 15 ++-- 7 files changed, 221 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/_image.pyi b/lib/matplotlib/_image.pyi index e69de29bb2d1..eb2a0e0ce5ce 100644 --- a/lib/matplotlib/_image.pyi +++ b/lib/matplotlib/_image.pyi @@ -0,0 +1,60 @@ +# Stub generated from the C++ (pybind11) signatures in ``src/_image_wrapper.cpp``. +# NOT verified with matplotlib's ``stubtest`` (the extension was not importable +# when this stub was written); treat the annotations as best-effort. +from enum import Enum +from typing import cast + +import numpy as np + +class _InterpolationType(Enum): + NEAREST = cast(int, ...) + BILINEAR = cast(int, ...) + BICUBIC = cast(int, ...) + SPLINE16 = cast(int, ...) + SPLINE36 = cast(int, ...) + HANNING = cast(int, ...) + HAMMING = cast(int, ...) + HERMITE = cast(int, ...) + KAISER = cast(int, ...) + QUADRIC = cast(int, ...) + CATROM = cast(int, ...) + GAUSSIAN = cast(int, ...) + BESSEL = cast(int, ...) + MITCHELL = cast(int, ...) + SINC = cast(int, ...) + LANCZOS = cast(int, ...) + BLACKMAN = cast(int, ...) + +# ``_InterpolationType`` uses ``.export_values()``, so every member is also +# injected as a module-level constant. +NEAREST: _InterpolationType +BILINEAR: _InterpolationType +BICUBIC: _InterpolationType +SPLINE16: _InterpolationType +SPLINE36: _InterpolationType +HANNING: _InterpolationType +HAMMING: _InterpolationType +HERMITE: _InterpolationType +KAISER: _InterpolationType +QUADRIC: _InterpolationType +CATROM: _InterpolationType +GAUSSIAN: _InterpolationType +BESSEL: _InterpolationType +MITCHELL: _InterpolationType +SINC: _InterpolationType +LANCZOS: _InterpolationType +BLACKMAN: _InterpolationType + +def resample( + input_array: np.ndarray, + output_array: np.ndarray, + transform: np.ndarray, + interpolation: _InterpolationType = ..., + resample: bool = ..., + alpha: float = ..., + norm: bool = ..., + radius: float = ..., +) -> None: ... +def calculate_rms_and_diff( + expected_image: np.ndarray, actual_image: np.ndarray +) -> tuple[float, np.ndarray]: ... diff --git a/lib/matplotlib/_path.pyi b/lib/matplotlib/_path.pyi index ee9c46483ec9..a0709c3b1f44 100644 --- a/lib/matplotlib/_path.pyi +++ b/lib/matplotlib/_path.pyi @@ -1,8 +1,7 @@ -from collections.abc import Sequence import numpy as np from .transforms import BboxBase def affine_transform(points: np.ndarray, trans: np.ndarray) -> np.ndarray: ... -def count_bboxes_overlapping_bbox(bbox: BboxBase, bboxes: Sequence[BboxBase]) -> int: ... +def count_bboxes_overlapping_bbox(bbox: BboxBase, bboxes: np.ndarray) -> int: ... diff --git a/lib/matplotlib/_qhull.pyi b/lib/matplotlib/_qhull.pyi index e69de29bb2d1..4aa575902e2a 100644 --- a/lib/matplotlib/_qhull.pyi +++ b/lib/matplotlib/_qhull.pyi @@ -0,0 +1,9 @@ +# Stub generated from the C++ (pybind11) signatures in ``src/_qhull_wrapper.cpp``. +# NOT verified with matplotlib's ``stubtest`` (the extension was not importable +# when this stub was written); treat the annotations as best-effort. +import numpy as np + +def delaunay( + x: np.ndarray, y: np.ndarray, verbose: int +) -> tuple[np.ndarray, np.ndarray]: ... +def version() -> str: ... diff --git a/lib/matplotlib/_tri.pyi b/lib/matplotlib/_tri.pyi index a0c710fc2309..bbefde330d28 100644 --- a/lib/matplotlib/_tri.pyi +++ b/lib/matplotlib/_tri.pyi @@ -28,7 +28,7 @@ class Triangulation: mask: npt.NDArray[np.bool_] | tuple[()], edges: npt.NDArray[np.int_] | tuple[()], neighbors: npt.NDArray[np.int_] | tuple[()], - correct_triangle_orientation: bool, + correct_triangle_orientations: bool, ): ... def calculate_plane_coefficients(self, z: npt.ArrayLike) -> npt.NDArray[np.float64]: ... def get_edges(self) -> npt.NDArray[np.int_]: ... diff --git a/lib/matplotlib/backends/_backend_agg.pyi b/lib/matplotlib/backends/_backend_agg.pyi index e69de29bb2d1..b925c2b5b286 100644 --- a/lib/matplotlib/backends/_backend_agg.pyi +++ b/lib/matplotlib/backends/_backend_agg.pyi @@ -0,0 +1,103 @@ +# Stub generated from the C++ (pybind11) signatures in +# ``src/_backend_agg_wrapper.cpp``. NOT verified with matplotlib's ``stubtest`` +# (the extension was not importable when this stub was written); the +# graphics-primitive argument types in particular are best-effort. +from collections.abc import Buffer, Sequence +from typing import overload + +import numpy as np + +from matplotlib.backend_bases import GraphicsContextBase +from matplotlib.path import Path +from matplotlib.transforms import Bbox, Transform + +class BufferRegion(Buffer): + # Not constructible from Python. + def set_x(self, x: int) -> None: ... + def set_y(self, y: int) -> None: ... + def get_extents(self) -> tuple[int, int, int, int]: ... + def __buffer__(self, flags: int, /) -> memoryview: ... + +class RendererAgg(Buffer): + def __init__(self, width: int, height: int, dpi: float) -> None: ... + def draw_path( + self, + gc: GraphicsContextBase, + path: Path, + trans: Transform, + face: np.ndarray | None = ..., + ) -> None: ... + def draw_markers( + self, + gc: GraphicsContextBase, + marker_path: Path, + marker_path_trans: Transform, + path: Path, + trans: Transform, + face: np.ndarray | None = ..., + ) -> None: ... + def draw_text_image( + self, + image: np.ndarray, + x: float, + y: float, + angle: float, + gc: GraphicsContextBase, + ) -> None: ... + def draw_image( + self, gc: GraphicsContextBase, x: float, y: float, image: np.ndarray + ) -> None: ... + def draw_path_collection( + self, + gc: GraphicsContextBase, + master_transform: Transform, + paths: Sequence[Path], + transforms: Sequence[np.ndarray], + offsets: np.ndarray, + offset_trans: Transform, + facecolors: np.ndarray, + edgecolors: np.ndarray, + linewidths: np.ndarray, + dashes: Sequence, + antialiaseds: np.ndarray, + ignored: object, + offset_position: object, + *, + hatchcolors: np.ndarray = ..., + ) -> None: ... + def draw_quad_mesh( + self, + gc: GraphicsContextBase, + master_transform: Transform, + mesh_width: int, + mesh_height: int, + coordinates: np.ndarray, + offsets: np.ndarray, + offset_trans: Transform, + facecolors: np.ndarray, + antialiased: bool, + edgecolors: np.ndarray, + ) -> None: ... + def draw_gouraud_triangles( + self, + gc: GraphicsContextBase, + points: np.ndarray, + colors: np.ndarray, + trans: Transform | None = ..., + ) -> None: ... + def clear(self) -> None: ... + def copy_from_bbox(self, bbox: Bbox) -> BufferRegion: ... + @overload + def restore_region(self, region: BufferRegion) -> None: ... + @overload + def restore_region( + self, + region: BufferRegion, + xx1: int, + yy1: int, + xx2: int, + yy2: int, + x: int, + y: int, + ) -> None: ... + def __buffer__(self, flags: int, /) -> memoryview: ... diff --git a/lib/matplotlib/backends/_macosx.pyi b/lib/matplotlib/backends/_macosx.pyi index e69de29bb2d1..5aee44476036 100644 --- a/lib/matplotlib/backends/_macosx.pyi +++ b/lib/matplotlib/backends/_macosx.pyi @@ -0,0 +1,38 @@ +# macOS-only native backend. Stub generated from the C types/methods in +# ``src/_macosx.m``. NOT verified with matplotlib's ``stubtest`` (the module is +# unavailable off-macOS and was not importable when this stub was written); the +# annotations, especially argument types, are best-effort. + +def event_loop_is_running() -> bool: ... +def wake_on_fd_write(fd: int) -> None: ... +def stop() -> None: ... +def show() -> None: ... +def choose_save_file( + title: str, directory: str, default_filename: str +) -> str | None: ... + +class FigureCanvas: + def update(self) -> None: ... + def flush_events(self) -> None: ... + def set_cursor(self, cursor: int) -> None: ... + def set_rubberband(self, x0: int, y0: int, x1: int, y1: int) -> None: ... + def remove_rubberband(self) -> None: ... + def _start_event_loop(self, timeout: float = ...) -> None: ... + def stop_event_loop(self) -> None: ... + +class FigureManager: + def _show(self) -> None: ... + def _raise(self) -> None: ... + def destroy(self) -> None: ... + def _set_window_mode(self, mode: str) -> None: ... + @staticmethod + def set_icon(path: str) -> None: ... + def set_window_title(self, title: str) -> None: ... + def get_window_title(self) -> str: ... + +class NavigationToolbar2: + def set_message(self, message: str) -> None: ... + +class Timer: + def _timer_start(self, *args: object) -> None: ... + def _timer_stop(self) -> None: ... diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index 882cde6c7ae9..b302a9c9aae0 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -244,18 +244,21 @@ class FT2Font(Buffer): def __buffer__(self, flags: int, /) -> memoryview: ... def _layout( self, - text: str, + string: str, flags: LoadFlags, + *, features: tuple[str, ...] | None = ..., language: str | tuple[tuple[str, int, int], ...] | None = ..., ) -> list[LayoutItem]: ... def clear(self) -> None: ... def draw_glyph_to_bitmap( - self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, antialiased: bool = ... + self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, *, antialiased: bool = ... ) -> None: ... - def draw_glyphs_to_bitmap(self, antialiased: bool = ...) -> None: ... + def draw_glyphs_to_bitmap(self, *, antialiased: bool = ...) -> None: ... def get_bitmap_offset(self) -> tuple[int, int]: ... - def get_char_index(self, codepoint: CharacterCodeType) -> GlyphIndexType: ... + def get_char_index( + self, codepoint: CharacterCodeType, *, _fallback: bool = ... + ) -> GlyphIndexType: ... def get_charmap(self) -> dict[CharacterCodeType, GlyphIndexType]: ... def get_descent(self) -> int: ... def get_glyph_name(self, index: GlyphIndexType) -> str: ... @@ -284,7 +287,7 @@ class FT2Font(Buffer): def get_sfnt_table(self, name: Literal["pclt"]) -> _SfntPcltDict | None: ... def get_width_height(self) -> tuple[int, int]: ... def load_char(self, charcode: CharacterCodeType, flags: LoadFlags = ...) -> Glyph: ... - def load_glyph(self, glyphindex: GlyphIndexType, flags: LoadFlags = ...) -> Glyph: ... + def load_glyph(self, glyph_index: GlyphIndexType, flags: LoadFlags = ...) -> Glyph: ... def select_charmap(self, i: int) -> None: ... def set_charmap(self, i: int) -> None: ... def set_size(self, ptsize: float, dpi: float) -> None: ... @@ -294,7 +297,7 @@ class FT2Font(Buffer): angle: float = ..., flags: LoadFlags = ..., *, - features: tuple[str] | None = ..., + features: tuple[str, ...] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> NDArray[np.float64]: ... @property From fef1b66ab28f428cbaa4ba8a0514c103cedb6331 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 14:33:19 -0400 Subject: [PATCH 02/11] unique_ptrs for buffers within AGG backend --- src/_backend_agg.cpp | 15 ++++++--------- src/_backend_agg.h | 7 ++++--- src/_backend_agg_wrapper.cpp | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/_backend_agg.cpp b/src/_backend_agg.cpp index aba284719df0..121fa63ac2f6 100644 --- a/src/_backend_agg.cpp +++ b/src/_backend_agg.cpp @@ -41,30 +41,27 @@ RendererAgg::RendererAgg(unsigned int width, unsigned int height, double dpi) unsigned stride(width * 4); - pixBuffer = new agg::int8u[NUMBYTES]; - renderingBuffer.attach(pixBuffer, width, height, stride); + pixBuffer.reset(new agg::int8u[NUMBYTES]); + renderingBuffer.attach(pixBuffer.get(), width, height, stride); pixFmt.attach(renderingBuffer); rendererBase.attach(pixFmt); rendererBase.clear(_fill_color); rendererAA.attach(rendererBase); rendererBin.attach(rendererBase); hatch_size = int(dpi); - hatchBuffer = new agg::int8u[hatch_size * hatch_size * 4]; - hatchRenderingBuffer.attach(hatchBuffer, hatch_size, hatch_size, hatch_size * 4); + hatchBuffer.reset(new agg::int8u[hatch_size * hatch_size * 4]); + hatchRenderingBuffer.attach(hatchBuffer.get(), hatch_size, hatch_size, hatch_size * 4); } RendererAgg::~RendererAgg() { - delete[] hatchBuffer; - delete[] alphaBuffer; - delete[] pixBuffer; } void RendererAgg::create_alpha_buffers() { if (!alphaBuffer) { - alphaBuffer = new agg::int8u[width * height]; - alphaMaskRenderingBuffer.attach(alphaBuffer, width, height, width); + alphaBuffer.reset(new agg::int8u[width * height]); + alphaMaskRenderingBuffer.attach(alphaBuffer.get(), width, height, width); rendererBaseAlphaMask.attach(pixfmtAlphaMask); rendererAlphaMask.attach(rendererBaseAlphaMask); } diff --git a/src/_backend_agg.h b/src/_backend_agg.h index a4e2aa10040d..d59157c3dec3 100644 --- a/src/_backend_agg.h +++ b/src/_backend_agg.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -206,10 +207,10 @@ class RendererAgg double dpi; size_t NUMBYTES; // the number of bytes in buffer - agg::int8u *pixBuffer; + std::unique_ptr pixBuffer; agg::rendering_buffer renderingBuffer; - agg::int8u *alphaBuffer; + std::unique_ptr alphaBuffer; agg::rendering_buffer alphaMaskRenderingBuffer; alpha_mask_type alphaMask; agg::pixfmt_gray8 pixfmtAlphaMask; @@ -229,7 +230,7 @@ class RendererAgg agg::trans_affine lastclippath_transform; size_t hatch_size; - agg::int8u *hatchBuffer; + std::unique_ptr hatchBuffer; agg::rendering_buffer hatchRenderingBuffer; agg::rgba _fill_color; diff --git a/src/_backend_agg_wrapper.cpp b/src/_backend_agg_wrapper.cpp index 3db347854da6..6840d99de6a2 100644 --- a/src/_backend_agg_wrapper.cpp +++ b/src/_backend_agg_wrapper.cpp @@ -234,7 +234,7 @@ PYBIND11_MODULE(_backend_agg, m, py::mod_gil_not_used()) 4, 1 }; - return py::buffer_info(renderer->pixBuffer, shape, strides); + return py::buffer_info(renderer->pixBuffer.get(), shape, strides); }); py::class_(m, "BufferRegion", py::buffer_protocol()) From aa3fc107e3facd53637a303beb0670c02dfe2305 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 14:34:50 -0400 Subject: [PATCH 03/11] Mark QhullInfo not copyable explicitly --- src/_qhull_wrapper.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/_qhull_wrapper.cpp b/src/_qhull_wrapper.cpp index 509b48ecb3e1..9f313826fac6 100644 --- a/src/_qhull_wrapper.cpp +++ b/src/_qhull_wrapper.cpp @@ -130,6 +130,10 @@ class QhullInfo { } } + // Uniquely owns qh and error_file; forbid copying (Rule of Three). + QhullInfo(const QhullInfo&) = delete; + QhullInfo& operator=(const QhullInfo&) = delete; + private: FILE* error_file; qhT* qh; From 2d43faaeedcce8a1b90cfbe57b2039a41f1eedbb Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 14:35:49 -0400 Subject: [PATCH 04/11] Handle possible cast exception within mpl_GetCurrentProcessExplicitAppUserModelID --- src/_c_internal_utils.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/_c_internal_utils.cpp b/src/_c_internal_utils.cpp index 31eb92444862..ed7e58c5a4c5 100644 --- a/src/_c_internal_utils.cpp +++ b/src/_c_internal_utils.cpp @@ -105,7 +105,13 @@ mpl_GetCurrentProcessExplicitAppUserModelID(void) PyErr_SetFromWindowsErr(hr); throw py::error_already_set(); } - auto py_appid = py::cast(appid); + py::object py_appid; + try { + py_appid = py::cast(appid); + } catch (...) { + CoTaskMemFree(appid); // don't leak the COM-allocated string on error + throw; + } CoTaskMemFree(appid); return py_appid; #else From e347f4479a72e5384ef1c378fd6819e4eb977342 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 14:37:50 -0400 Subject: [PATCH 05/11] Mutex in usages of PyFT2Font --- src/ft2font_wrapper.cpp | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index bef60445e26e..158cdd24d0a0 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -6,6 +6,8 @@ #include "ft2font.h" #include "_enums.h" +#include +#include #include #include #include @@ -360,6 +362,18 @@ class PyFT2Font final : public FT2Font FT_StreamRec stream; py::list fallbacks; + // Serializes this object's mutating Python-facing methods so that concurrent + // calls on the SAME FT2Font (under free-threading / py::mod_gil_not_used) + // cannot corrupt its FreeType glyph slot, glyph list or image buffer. It is + // taken ONLY at the wrapper boundary -- never inside FT2Font's C++ methods, + // which also run during construction and fallback recursion -- so a single + // lock is held per top-level call and it never nests (deadlock-free by + // construction). Recursive to tolerate re-entry via Python callbacks such as + // ft_glyph_warn(). Contract: one FT2Font (together with its fallback graph) + // is used by a single thread at a time; matplotlib enforces this via the + // thread-keyed cache in font_manager._get_font. + std::recursive_mutex mutex; + ~PyFT2Font() { // Because destructors are called from subclass up to base class, we need to @@ -503,7 +517,9 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu std::back_inserter(fallback_fonts)); } - auto self = new PyFT2Font(fallback_fonts, warn_if_used); + // Own the object locally so it is released if any step below throws (e.g. a + // corrupt font makes self->open() fail); pybind11 adopts it only on success. + auto self = std::make_unique(fallback_fonts, warn_if_used); self->set_kerning_factor(*kerning_factor); if (fallback_list) { @@ -517,7 +533,7 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu self->stream.base = nullptr; self->stream.size = 0x7fffffff; // Unknown size. self->stream.pos = 0; - self->stream.descriptor.pointer = self; + self->stream.descriptor.pointer = self.get(); self->stream.read = &read_from_file_callback; FT_Open_Args open_args; memset((void *)&open_args, 0, sizeof(FT_Open_Args)); @@ -547,7 +563,7 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu self->open(open_args, face_index); - return self; + return self.release(); } static py::object @@ -687,6 +703,7 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 std::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { + std::scoped_lock lock{self->mutex}; std::vector xys; FT2Font::LanguageType languages; @@ -745,6 +762,7 @@ static PyGlyph * PyFT2Font_load_char(PyFT2Font *self, long charcode, LoadFlags flags = LoadFlags::FORCE_AUTOHINT) { + std::scoped_lock lock{self->mutex}; bool fallback = true; FT2Font *ft_object = nullptr; @@ -783,6 +801,7 @@ static PyGlyph * PyFT2Font_load_glyph(PyFT2Font *self, FT_UInt glyph_index, LoadFlags flags = LoadFlags::FORCE_AUTOHINT) { + std::scoped_lock lock{self->mutex}; self->load_glyph(glyph_index, static_cast(flags)); return PyGlyph_from_FT2Font(self); @@ -885,6 +904,7 @@ PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, py::buffer &image, int xd, int yd, PyGlyph *glyph, bool antialiased = true) { + std::scoped_lock lock{self->mutex}; self->draw_glyph_to_bitmap( py::array_t{image}, xd, yd, glyph->glyphInd, antialiased); @@ -1409,6 +1429,7 @@ PyFT2Font_layout(PyFT2Font *self, std::u32string text, LoadFlags flags, std::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { + std::scoped_lock lock{self->mutex}; const auto load_flags = static_cast(flags); FT2Font::LanguageType languages; @@ -1577,7 +1598,12 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) "_fallback_list"_a=py::none(), "_kerning_factor"_a=py::none(), "_warn_if_used"_a=false, PyFT2Font_init__doc__) - .def("clear", &PyFT2Font::clear, PyFT2Font_clear__doc__) + .def("clear", + [](PyFT2Font& self) { + std::scoped_lock lock{self.mutex}; + self.clear(); + }, + PyFT2Font_clear__doc__) .def("set_size", &PyFT2Font::set_size, "ptsize"_a, "dpi"_a, PyFT2Font_set_size__doc__) .def("_set_transform", &PyFT2Font::_set_transform, "matrix"_a, "delta"_a, @@ -1608,7 +1634,11 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_bitmap_offset", &PyFT2Font::get_bitmap_offset, PyFT2Font_get_bitmap_offset__doc__) .def("get_descent", &PyFT2Font::get_descent, PyFT2Font_get_descent__doc__) - .def("draw_glyphs_to_bitmap", &PyFT2Font::draw_glyphs_to_bitmap, + .def("draw_glyphs_to_bitmap", + [](PyFT2Font& self, bool antialiased) { + std::scoped_lock lock{self.mutex}; + self.draw_glyphs_to_bitmap(antialiased); + }, py::kw_only(), "antialiased"_a=true, PyFT2Font_draw_glyphs_to_bitmap__doc__); // The generated docstring uses an unqualified "Buffer" as type hint, From 61ff82281070a9cca1e022532cac24b3b06df521 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 14:39:23 -0400 Subject: [PATCH 06/11] Mutex around usages of global FT_Library variable --- src/ft2font.cpp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index 3e53a87343a6..c82fd0622cae 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -18,9 +20,22 @@ FT_Library _ft2Library; +// FreeType is not thread-safe when a single FT_Library is shared across +// threads: FT_Open_Face and FT_Done_Face mutate the library's internal list of +// faces. The extension modules declare py::mod_gil_not_used(), so these can run +// concurrently under free-threading; serialize them with this mutex. Per-face +// glyph loading and bitmap conversion only use the (malloc-based) memory +// manager and are deliberately left unguarded to preserve render parallelism. +static std::mutex _ft2LibraryMutex; + FT2Image::FT2Image(unsigned long width, unsigned long height) - : m_buffer((unsigned char *)calloc(width * height, 1)), m_width(width), m_height(height) + // calloc(width, height) performs the width*height overflow check internally + // and returns nullptr on overflow or allocation failure. + : m_buffer((unsigned char *)calloc(width, height)), m_width(width), m_height(height) { + if (!m_buffer && width != 0 && height != 0) { + throw std::bad_alloc(); + } } FT2Image::~FT2Image() @@ -197,7 +212,11 @@ FT2Font::~FT2Font() void FT2Font::open(FT_Open_Args &open_args, FT_Long face_index) { - FT_CHECK(FT_Open_Face, _ft2Library, &open_args, face_index, &face); + { + // FT_Open_Face mutates the shared _ft2Library; serialize it. + std::scoped_lock lock{_ft2LibraryMutex}; + FT_CHECK(FT_Open_Face, _ft2Library, &open_args, face_index, &face); + } if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } @@ -220,6 +239,8 @@ void FT2Font::close() glyphs.clear(); if (face) { + // FT_Done_Face mutates the shared _ft2Library; serialize it. + std::scoped_lock lock{_ft2LibraryMutex}; FT_Done_Face(face); face = nullptr; } From 6674323d4eb2fed51dc4cd79e8bc636ae6f6cd55 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 15:03:07 -0400 Subject: [PATCH 07/11] Actually correcly annoate mypy stub for image --- lib/matplotlib/_image.pyi | 60 ------------------------------------- lib/matplotlib/image.pyi | 63 +++++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 79 deletions(-) diff --git a/lib/matplotlib/_image.pyi b/lib/matplotlib/_image.pyi index eb2a0e0ce5ce..e69de29bb2d1 100644 --- a/lib/matplotlib/_image.pyi +++ b/lib/matplotlib/_image.pyi @@ -1,60 +0,0 @@ -# Stub generated from the C++ (pybind11) signatures in ``src/_image_wrapper.cpp``. -# NOT verified with matplotlib's ``stubtest`` (the extension was not importable -# when this stub was written); treat the annotations as best-effort. -from enum import Enum -from typing import cast - -import numpy as np - -class _InterpolationType(Enum): - NEAREST = cast(int, ...) - BILINEAR = cast(int, ...) - BICUBIC = cast(int, ...) - SPLINE16 = cast(int, ...) - SPLINE36 = cast(int, ...) - HANNING = cast(int, ...) - HAMMING = cast(int, ...) - HERMITE = cast(int, ...) - KAISER = cast(int, ...) - QUADRIC = cast(int, ...) - CATROM = cast(int, ...) - GAUSSIAN = cast(int, ...) - BESSEL = cast(int, ...) - MITCHELL = cast(int, ...) - SINC = cast(int, ...) - LANCZOS = cast(int, ...) - BLACKMAN = cast(int, ...) - -# ``_InterpolationType`` uses ``.export_values()``, so every member is also -# injected as a module-level constant. -NEAREST: _InterpolationType -BILINEAR: _InterpolationType -BICUBIC: _InterpolationType -SPLINE16: _InterpolationType -SPLINE36: _InterpolationType -HANNING: _InterpolationType -HAMMING: _InterpolationType -HERMITE: _InterpolationType -KAISER: _InterpolationType -QUADRIC: _InterpolationType -CATROM: _InterpolationType -GAUSSIAN: _InterpolationType -BESSEL: _InterpolationType -MITCHELL: _InterpolationType -SINC: _InterpolationType -LANCZOS: _InterpolationType -BLACKMAN: _InterpolationType - -def resample( - input_array: np.ndarray, - output_array: np.ndarray, - transform: np.ndarray, - interpolation: _InterpolationType = ..., - resample: bool = ..., - alpha: float = ..., - norm: bool = ..., - radius: float = ..., -) -> None: ... -def calculate_rms_and_diff( - expected_image: np.ndarray, actual_image: np.ndarray -) -> tuple[float, np.ndarray]: ... diff --git a/lib/matplotlib/image.pyi b/lib/matplotlib/image.pyi index 4c2a8fbdb0e6..3d1d2eb8758c 100644 --- a/lib/matplotlib/image.pyi +++ b/lib/matplotlib/image.pyi @@ -1,7 +1,8 @@ from collections.abc import Callable, Sequence +from enum import Enum import os import pathlib -from typing import Any, BinaryIO, Literal +from typing import Any, BinaryIO, Literal, cast import numpy as np from numpy.typing import ArrayLike, NDArray @@ -19,34 +20,58 @@ from matplotlib.transforms import Affine2D, BboxBase, Bbox, Transform # These names are re-exported from matplotlib._image. # -BESSEL: int -BICUBIC: int -BILINEAR: int -BLACKMAN: int -CATROM: int -GAUSSIAN: int -HAMMING: int -HANNING: int -HERMITE: int -KAISER: int -LANCZOS: int -MITCHELL: int -NEAREST: int -QUADRIC: int -SINC: int -SPLINE16: int -SPLINE36: int +class _InterpolationType(Enum): + NEAREST = cast(int, ...) + BILINEAR = cast(int, ...) + BICUBIC = cast(int, ...) + SPLINE16 = cast(int, ...) + SPLINE36 = cast(int, ...) + HANNING = cast(int, ...) + HAMMING = cast(int, ...) + HERMITE = cast(int, ...) + KAISER = cast(int, ...) + QUADRIC = cast(int, ...) + CATROM = cast(int, ...) + GAUSSIAN = cast(int, ...) + BESSEL = cast(int, ...) + MITCHELL = cast(int, ...) + SINC = cast(int, ...) + LANCZOS = cast(int, ...) + BLACKMAN = cast(int, ...) + +# ``_InterpolationType`` uses ``.export_values()``, so every member is also +# injected as a module-level constant. +NEAREST: _InterpolationType +BILINEAR: _InterpolationType +BICUBIC: _InterpolationType +SPLINE16: _InterpolationType +SPLINE36: _InterpolationType +HANNING: _InterpolationType +HAMMING: _InterpolationType +HERMITE: _InterpolationType +KAISER: _InterpolationType +QUADRIC: _InterpolationType +CATROM: _InterpolationType +GAUSSIAN: _InterpolationType +BESSEL: _InterpolationType +MITCHELL: _InterpolationType +SINC: _InterpolationType +LANCZOS: _InterpolationType +BLACKMAN: _InterpolationType def resample( input_array: NDArray[np.float32] | NDArray[np.float64] | NDArray[np.int8], output_array: NDArray[np.float32] | NDArray[np.float64] | NDArray[np.int8], transform: Transform, - interpolation: int = ..., + interpolation: _InterpolationType = ..., resample: bool = ..., alpha: float = ..., norm: bool = ..., radius: float = ..., ) -> None: ... +def calculate_rms_and_diff( + expected_image: np.ndarray, actual_image: np.ndarray +) -> tuple[float, np.ndarray]: ... # # END names re-exported from matplotlib._image. From 8eeb5675d935a7e8af6508ec700c1c15980d389d Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 15:14:32 -0400 Subject: [PATCH 08/11] Revert type stub changes in this branch --- lib/matplotlib/_path.pyi | 3 +- lib/matplotlib/_qhull.pyi | 9 -- lib/matplotlib/_tri.pyi | 2 +- lib/matplotlib/backends/_backend_agg.pyi | 103 ----------------------- lib/matplotlib/backends/_macosx.pyi | 38 --------- lib/matplotlib/ft2font.pyi | 15 ++-- lib/matplotlib/image.pyi | 63 +++++--------- 7 files changed, 28 insertions(+), 205 deletions(-) diff --git a/lib/matplotlib/_path.pyi b/lib/matplotlib/_path.pyi index a0709c3b1f44..ee9c46483ec9 100644 --- a/lib/matplotlib/_path.pyi +++ b/lib/matplotlib/_path.pyi @@ -1,7 +1,8 @@ +from collections.abc import Sequence import numpy as np from .transforms import BboxBase def affine_transform(points: np.ndarray, trans: np.ndarray) -> np.ndarray: ... -def count_bboxes_overlapping_bbox(bbox: BboxBase, bboxes: np.ndarray) -> int: ... +def count_bboxes_overlapping_bbox(bbox: BboxBase, bboxes: Sequence[BboxBase]) -> int: ... diff --git a/lib/matplotlib/_qhull.pyi b/lib/matplotlib/_qhull.pyi index 4aa575902e2a..e69de29bb2d1 100644 --- a/lib/matplotlib/_qhull.pyi +++ b/lib/matplotlib/_qhull.pyi @@ -1,9 +0,0 @@ -# Stub generated from the C++ (pybind11) signatures in ``src/_qhull_wrapper.cpp``. -# NOT verified with matplotlib's ``stubtest`` (the extension was not importable -# when this stub was written); treat the annotations as best-effort. -import numpy as np - -def delaunay( - x: np.ndarray, y: np.ndarray, verbose: int -) -> tuple[np.ndarray, np.ndarray]: ... -def version() -> str: ... diff --git a/lib/matplotlib/_tri.pyi b/lib/matplotlib/_tri.pyi index bbefde330d28..a0c710fc2309 100644 --- a/lib/matplotlib/_tri.pyi +++ b/lib/matplotlib/_tri.pyi @@ -28,7 +28,7 @@ class Triangulation: mask: npt.NDArray[np.bool_] | tuple[()], edges: npt.NDArray[np.int_] | tuple[()], neighbors: npt.NDArray[np.int_] | tuple[()], - correct_triangle_orientations: bool, + correct_triangle_orientation: bool, ): ... def calculate_plane_coefficients(self, z: npt.ArrayLike) -> npt.NDArray[np.float64]: ... def get_edges(self) -> npt.NDArray[np.int_]: ... diff --git a/lib/matplotlib/backends/_backend_agg.pyi b/lib/matplotlib/backends/_backend_agg.pyi index b925c2b5b286..e69de29bb2d1 100644 --- a/lib/matplotlib/backends/_backend_agg.pyi +++ b/lib/matplotlib/backends/_backend_agg.pyi @@ -1,103 +0,0 @@ -# Stub generated from the C++ (pybind11) signatures in -# ``src/_backend_agg_wrapper.cpp``. NOT verified with matplotlib's ``stubtest`` -# (the extension was not importable when this stub was written); the -# graphics-primitive argument types in particular are best-effort. -from collections.abc import Buffer, Sequence -from typing import overload - -import numpy as np - -from matplotlib.backend_bases import GraphicsContextBase -from matplotlib.path import Path -from matplotlib.transforms import Bbox, Transform - -class BufferRegion(Buffer): - # Not constructible from Python. - def set_x(self, x: int) -> None: ... - def set_y(self, y: int) -> None: ... - def get_extents(self) -> tuple[int, int, int, int]: ... - def __buffer__(self, flags: int, /) -> memoryview: ... - -class RendererAgg(Buffer): - def __init__(self, width: int, height: int, dpi: float) -> None: ... - def draw_path( - self, - gc: GraphicsContextBase, - path: Path, - trans: Transform, - face: np.ndarray | None = ..., - ) -> None: ... - def draw_markers( - self, - gc: GraphicsContextBase, - marker_path: Path, - marker_path_trans: Transform, - path: Path, - trans: Transform, - face: np.ndarray | None = ..., - ) -> None: ... - def draw_text_image( - self, - image: np.ndarray, - x: float, - y: float, - angle: float, - gc: GraphicsContextBase, - ) -> None: ... - def draw_image( - self, gc: GraphicsContextBase, x: float, y: float, image: np.ndarray - ) -> None: ... - def draw_path_collection( - self, - gc: GraphicsContextBase, - master_transform: Transform, - paths: Sequence[Path], - transforms: Sequence[np.ndarray], - offsets: np.ndarray, - offset_trans: Transform, - facecolors: np.ndarray, - edgecolors: np.ndarray, - linewidths: np.ndarray, - dashes: Sequence, - antialiaseds: np.ndarray, - ignored: object, - offset_position: object, - *, - hatchcolors: np.ndarray = ..., - ) -> None: ... - def draw_quad_mesh( - self, - gc: GraphicsContextBase, - master_transform: Transform, - mesh_width: int, - mesh_height: int, - coordinates: np.ndarray, - offsets: np.ndarray, - offset_trans: Transform, - facecolors: np.ndarray, - antialiased: bool, - edgecolors: np.ndarray, - ) -> None: ... - def draw_gouraud_triangles( - self, - gc: GraphicsContextBase, - points: np.ndarray, - colors: np.ndarray, - trans: Transform | None = ..., - ) -> None: ... - def clear(self) -> None: ... - def copy_from_bbox(self, bbox: Bbox) -> BufferRegion: ... - @overload - def restore_region(self, region: BufferRegion) -> None: ... - @overload - def restore_region( - self, - region: BufferRegion, - xx1: int, - yy1: int, - xx2: int, - yy2: int, - x: int, - y: int, - ) -> None: ... - def __buffer__(self, flags: int, /) -> memoryview: ... diff --git a/lib/matplotlib/backends/_macosx.pyi b/lib/matplotlib/backends/_macosx.pyi index 5aee44476036..e69de29bb2d1 100644 --- a/lib/matplotlib/backends/_macosx.pyi +++ b/lib/matplotlib/backends/_macosx.pyi @@ -1,38 +0,0 @@ -# macOS-only native backend. Stub generated from the C types/methods in -# ``src/_macosx.m``. NOT verified with matplotlib's ``stubtest`` (the module is -# unavailable off-macOS and was not importable when this stub was written); the -# annotations, especially argument types, are best-effort. - -def event_loop_is_running() -> bool: ... -def wake_on_fd_write(fd: int) -> None: ... -def stop() -> None: ... -def show() -> None: ... -def choose_save_file( - title: str, directory: str, default_filename: str -) -> str | None: ... - -class FigureCanvas: - def update(self) -> None: ... - def flush_events(self) -> None: ... - def set_cursor(self, cursor: int) -> None: ... - def set_rubberband(self, x0: int, y0: int, x1: int, y1: int) -> None: ... - def remove_rubberband(self) -> None: ... - def _start_event_loop(self, timeout: float = ...) -> None: ... - def stop_event_loop(self) -> None: ... - -class FigureManager: - def _show(self) -> None: ... - def _raise(self) -> None: ... - def destroy(self) -> None: ... - def _set_window_mode(self, mode: str) -> None: ... - @staticmethod - def set_icon(path: str) -> None: ... - def set_window_title(self, title: str) -> None: ... - def get_window_title(self) -> str: ... - -class NavigationToolbar2: - def set_message(self, message: str) -> None: ... - -class Timer: - def _timer_start(self, *args: object) -> None: ... - def _timer_stop(self) -> None: ... diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index b302a9c9aae0..882cde6c7ae9 100644 --- a/lib/matplotlib/ft2font.pyi +++ b/lib/matplotlib/ft2font.pyi @@ -244,21 +244,18 @@ class FT2Font(Buffer): def __buffer__(self, flags: int, /) -> memoryview: ... def _layout( self, - string: str, + text: str, flags: LoadFlags, - *, features: tuple[str, ...] | None = ..., language: str | tuple[tuple[str, int, int], ...] | None = ..., ) -> list[LayoutItem]: ... def clear(self) -> None: ... def draw_glyph_to_bitmap( - self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, *, antialiased: bool = ... + self, image: NDArray[np.uint8], x: int, y: int, glyph: Glyph, antialiased: bool = ... ) -> None: ... - def draw_glyphs_to_bitmap(self, *, antialiased: bool = ...) -> None: ... + def draw_glyphs_to_bitmap(self, antialiased: bool = ...) -> None: ... def get_bitmap_offset(self) -> tuple[int, int]: ... - def get_char_index( - self, codepoint: CharacterCodeType, *, _fallback: bool = ... - ) -> GlyphIndexType: ... + def get_char_index(self, codepoint: CharacterCodeType) -> GlyphIndexType: ... def get_charmap(self) -> dict[CharacterCodeType, GlyphIndexType]: ... def get_descent(self) -> int: ... def get_glyph_name(self, index: GlyphIndexType) -> str: ... @@ -287,7 +284,7 @@ class FT2Font(Buffer): def get_sfnt_table(self, name: Literal["pclt"]) -> _SfntPcltDict | None: ... def get_width_height(self) -> tuple[int, int]: ... def load_char(self, charcode: CharacterCodeType, flags: LoadFlags = ...) -> Glyph: ... - def load_glyph(self, glyph_index: GlyphIndexType, flags: LoadFlags = ...) -> Glyph: ... + def load_glyph(self, glyphindex: GlyphIndexType, flags: LoadFlags = ...) -> Glyph: ... def select_charmap(self, i: int) -> None: ... def set_charmap(self, i: int) -> None: ... def set_size(self, ptsize: float, dpi: float) -> None: ... @@ -297,7 +294,7 @@ class FT2Font(Buffer): angle: float = ..., flags: LoadFlags = ..., *, - features: tuple[str, ...] | None = ..., + features: tuple[str] | None = ..., language: str | list[tuple[str, int, int]] | None = ..., ) -> NDArray[np.float64]: ... @property diff --git a/lib/matplotlib/image.pyi b/lib/matplotlib/image.pyi index 3d1d2eb8758c..4c2a8fbdb0e6 100644 --- a/lib/matplotlib/image.pyi +++ b/lib/matplotlib/image.pyi @@ -1,8 +1,7 @@ from collections.abc import Callable, Sequence -from enum import Enum import os import pathlib -from typing import Any, BinaryIO, Literal, cast +from typing import Any, BinaryIO, Literal import numpy as np from numpy.typing import ArrayLike, NDArray @@ -20,58 +19,34 @@ from matplotlib.transforms import Affine2D, BboxBase, Bbox, Transform # These names are re-exported from matplotlib._image. # -class _InterpolationType(Enum): - NEAREST = cast(int, ...) - BILINEAR = cast(int, ...) - BICUBIC = cast(int, ...) - SPLINE16 = cast(int, ...) - SPLINE36 = cast(int, ...) - HANNING = cast(int, ...) - HAMMING = cast(int, ...) - HERMITE = cast(int, ...) - KAISER = cast(int, ...) - QUADRIC = cast(int, ...) - CATROM = cast(int, ...) - GAUSSIAN = cast(int, ...) - BESSEL = cast(int, ...) - MITCHELL = cast(int, ...) - SINC = cast(int, ...) - LANCZOS = cast(int, ...) - BLACKMAN = cast(int, ...) - -# ``_InterpolationType`` uses ``.export_values()``, so every member is also -# injected as a module-level constant. -NEAREST: _InterpolationType -BILINEAR: _InterpolationType -BICUBIC: _InterpolationType -SPLINE16: _InterpolationType -SPLINE36: _InterpolationType -HANNING: _InterpolationType -HAMMING: _InterpolationType -HERMITE: _InterpolationType -KAISER: _InterpolationType -QUADRIC: _InterpolationType -CATROM: _InterpolationType -GAUSSIAN: _InterpolationType -BESSEL: _InterpolationType -MITCHELL: _InterpolationType -SINC: _InterpolationType -LANCZOS: _InterpolationType -BLACKMAN: _InterpolationType +BESSEL: int +BICUBIC: int +BILINEAR: int +BLACKMAN: int +CATROM: int +GAUSSIAN: int +HAMMING: int +HANNING: int +HERMITE: int +KAISER: int +LANCZOS: int +MITCHELL: int +NEAREST: int +QUADRIC: int +SINC: int +SPLINE16: int +SPLINE36: int def resample( input_array: NDArray[np.float32] | NDArray[np.float64] | NDArray[np.int8], output_array: NDArray[np.float32] | NDArray[np.float64] | NDArray[np.int8], transform: Transform, - interpolation: _InterpolationType = ..., + interpolation: int = ..., resample: bool = ..., alpha: float = ..., norm: bool = ..., radius: float = ..., ) -> None: ... -def calculate_rms_and_diff( - expected_image: np.ndarray, actual_image: np.ndarray -) -> tuple[float, np.ndarray]: ... # # END names re-exported from matplotlib._image. From e3ad87c7ff521070496880f302c2e1929e36ee78 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 16:05:42 -0400 Subject: [PATCH 09/11] Revert "Mutex in usages of PyFT2Font" This reverts commit e347f4479a72e5384ef1c378fd6819e4eb977342. --- src/ft2font_wrapper.cpp | 40 +++++----------------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 158cdd24d0a0..bef60445e26e 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -6,8 +6,6 @@ #include "ft2font.h" #include "_enums.h" -#include -#include #include #include #include @@ -362,18 +360,6 @@ class PyFT2Font final : public FT2Font FT_StreamRec stream; py::list fallbacks; - // Serializes this object's mutating Python-facing methods so that concurrent - // calls on the SAME FT2Font (under free-threading / py::mod_gil_not_used) - // cannot corrupt its FreeType glyph slot, glyph list or image buffer. It is - // taken ONLY at the wrapper boundary -- never inside FT2Font's C++ methods, - // which also run during construction and fallback recursion -- so a single - // lock is held per top-level call and it never nests (deadlock-free by - // construction). Recursive to tolerate re-entry via Python callbacks such as - // ft_glyph_warn(). Contract: one FT2Font (together with its fallback graph) - // is used by a single thread at a time; matplotlib enforces this via the - // thread-keyed cache in font_manager._get_font. - std::recursive_mutex mutex; - ~PyFT2Font() { // Because destructors are called from subclass up to base class, we need to @@ -517,9 +503,7 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu std::back_inserter(fallback_fonts)); } - // Own the object locally so it is released if any step below throws (e.g. a - // corrupt font makes self->open() fail); pybind11 adopts it only on success. - auto self = std::make_unique(fallback_fonts, warn_if_used); + auto self = new PyFT2Font(fallback_fonts, warn_if_used); self->set_kerning_factor(*kerning_factor); if (fallback_list) { @@ -533,7 +517,7 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu self->stream.base = nullptr; self->stream.size = 0x7fffffff; // Unknown size. self->stream.pos = 0; - self->stream.descriptor.pointer = self.get(); + self->stream.descriptor.pointer = self; self->stream.read = &read_from_file_callback; FT_Open_Args open_args; memset((void *)&open_args, 0, sizeof(FT_Open_Args)); @@ -563,7 +547,7 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu self->open(open_args, face_index); - return self.release(); + return self; } static py::object @@ -703,7 +687,6 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 std::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { - std::scoped_lock lock{self->mutex}; std::vector xys; FT2Font::LanguageType languages; @@ -762,7 +745,6 @@ static PyGlyph * PyFT2Font_load_char(PyFT2Font *self, long charcode, LoadFlags flags = LoadFlags::FORCE_AUTOHINT) { - std::scoped_lock lock{self->mutex}; bool fallback = true; FT2Font *ft_object = nullptr; @@ -801,7 +783,6 @@ static PyGlyph * PyFT2Font_load_glyph(PyFT2Font *self, FT_UInt glyph_index, LoadFlags flags = LoadFlags::FORCE_AUTOHINT) { - std::scoped_lock lock{self->mutex}; self->load_glyph(glyph_index, static_cast(flags)); return PyGlyph_from_FT2Font(self); @@ -904,7 +885,6 @@ PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, py::buffer &image, int xd, int yd, PyGlyph *glyph, bool antialiased = true) { - std::scoped_lock lock{self->mutex}; self->draw_glyph_to_bitmap( py::array_t{image}, xd, yd, glyph->glyphInd, antialiased); @@ -1429,7 +1409,6 @@ PyFT2Font_layout(PyFT2Font *self, std::u32string text, LoadFlags flags, std::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { - std::scoped_lock lock{self->mutex}; const auto load_flags = static_cast(flags); FT2Font::LanguageType languages; @@ -1598,12 +1577,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) "_fallback_list"_a=py::none(), "_kerning_factor"_a=py::none(), "_warn_if_used"_a=false, PyFT2Font_init__doc__) - .def("clear", - [](PyFT2Font& self) { - std::scoped_lock lock{self.mutex}; - self.clear(); - }, - PyFT2Font_clear__doc__) + .def("clear", &PyFT2Font::clear, PyFT2Font_clear__doc__) .def("set_size", &PyFT2Font::set_size, "ptsize"_a, "dpi"_a, PyFT2Font_set_size__doc__) .def("_set_transform", &PyFT2Font::_set_transform, "matrix"_a, "delta"_a, @@ -1634,11 +1608,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_bitmap_offset", &PyFT2Font::get_bitmap_offset, PyFT2Font_get_bitmap_offset__doc__) .def("get_descent", &PyFT2Font::get_descent, PyFT2Font_get_descent__doc__) - .def("draw_glyphs_to_bitmap", - [](PyFT2Font& self, bool antialiased) { - std::scoped_lock lock{self.mutex}; - self.draw_glyphs_to_bitmap(antialiased); - }, + .def("draw_glyphs_to_bitmap", &PyFT2Font::draw_glyphs_to_bitmap, py::kw_only(), "antialiased"_a=true, PyFT2Font_draw_glyphs_to_bitmap__doc__); // The generated docstring uses an unqualified "Buffer" as type hint, From 73f5fe0710732fa1fbddae6d3463cb5775e67c4f Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 16:20:06 -0400 Subject: [PATCH 10/11] Reapply "Mutex in usages of PyFT2Font" This reverts commit e3ad87c7ff521070496880f302c2e1929e36ee78. --- src/ft2font_wrapper.cpp | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index bef60445e26e..158cdd24d0a0 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -6,6 +6,8 @@ #include "ft2font.h" #include "_enums.h" +#include +#include #include #include #include @@ -360,6 +362,18 @@ class PyFT2Font final : public FT2Font FT_StreamRec stream; py::list fallbacks; + // Serializes this object's mutating Python-facing methods so that concurrent + // calls on the SAME FT2Font (under free-threading / py::mod_gil_not_used) + // cannot corrupt its FreeType glyph slot, glyph list or image buffer. It is + // taken ONLY at the wrapper boundary -- never inside FT2Font's C++ methods, + // which also run during construction and fallback recursion -- so a single + // lock is held per top-level call and it never nests (deadlock-free by + // construction). Recursive to tolerate re-entry via Python callbacks such as + // ft_glyph_warn(). Contract: one FT2Font (together with its fallback graph) + // is used by a single thread at a time; matplotlib enforces this via the + // thread-keyed cache in font_manager._get_font. + std::recursive_mutex mutex; + ~PyFT2Font() { // Because destructors are called from subclass up to base class, we need to @@ -503,7 +517,9 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu std::back_inserter(fallback_fonts)); } - auto self = new PyFT2Font(fallback_fonts, warn_if_used); + // Own the object locally so it is released if any step below throws (e.g. a + // corrupt font makes self->open() fail); pybind11 adopts it only on success. + auto self = std::make_unique(fallback_fonts, warn_if_used); self->set_kerning_factor(*kerning_factor); if (fallback_list) { @@ -517,7 +533,7 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu self->stream.base = nullptr; self->stream.size = 0x7fffffff; // Unknown size. self->stream.pos = 0; - self->stream.descriptor.pointer = self; + self->stream.descriptor.pointer = self.get(); self->stream.read = &read_from_file_callback; FT_Open_Args open_args; memset((void *)&open_args, 0, sizeof(FT_Open_Args)); @@ -547,7 +563,7 @@ PyFT2Font_init(py::object filename, std::optional hinting_factor = std::nu self->open(open_args, face_index); - return self; + return self.release(); } static py::object @@ -687,6 +703,7 @@ PyFT2Font_set_text(PyFT2Font *self, std::u32string_view text, double angle = 0.0 std::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { + std::scoped_lock lock{self->mutex}; std::vector xys; FT2Font::LanguageType languages; @@ -745,6 +762,7 @@ static PyGlyph * PyFT2Font_load_char(PyFT2Font *self, long charcode, LoadFlags flags = LoadFlags::FORCE_AUTOHINT) { + std::scoped_lock lock{self->mutex}; bool fallback = true; FT2Font *ft_object = nullptr; @@ -783,6 +801,7 @@ static PyGlyph * PyFT2Font_load_glyph(PyFT2Font *self, FT_UInt glyph_index, LoadFlags flags = LoadFlags::FORCE_AUTOHINT) { + std::scoped_lock lock{self->mutex}; self->load_glyph(glyph_index, static_cast(flags)); return PyGlyph_from_FT2Font(self); @@ -885,6 +904,7 @@ PyFT2Font_draw_glyph_to_bitmap(PyFT2Font *self, py::buffer &image, int xd, int yd, PyGlyph *glyph, bool antialiased = true) { + std::scoped_lock lock{self->mutex}; self->draw_glyph_to_bitmap( py::array_t{image}, xd, yd, glyph->glyphInd, antialiased); @@ -1409,6 +1429,7 @@ PyFT2Font_layout(PyFT2Font *self, std::u32string text, LoadFlags flags, std::optional> features = std::nullopt, std::variant languages_or_str = nullptr) { + std::scoped_lock lock{self->mutex}; const auto load_flags = static_cast(flags); FT2Font::LanguageType languages; @@ -1577,7 +1598,12 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) "_fallback_list"_a=py::none(), "_kerning_factor"_a=py::none(), "_warn_if_used"_a=false, PyFT2Font_init__doc__) - .def("clear", &PyFT2Font::clear, PyFT2Font_clear__doc__) + .def("clear", + [](PyFT2Font& self) { + std::scoped_lock lock{self.mutex}; + self.clear(); + }, + PyFT2Font_clear__doc__) .def("set_size", &PyFT2Font::set_size, "ptsize"_a, "dpi"_a, PyFT2Font_set_size__doc__) .def("_set_transform", &PyFT2Font::_set_transform, "matrix"_a, "delta"_a, @@ -1608,7 +1634,11 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used()) .def("get_bitmap_offset", &PyFT2Font::get_bitmap_offset, PyFT2Font_get_bitmap_offset__doc__) .def("get_descent", &PyFT2Font::get_descent, PyFT2Font_get_descent__doc__) - .def("draw_glyphs_to_bitmap", &PyFT2Font::draw_glyphs_to_bitmap, + .def("draw_glyphs_to_bitmap", + [](PyFT2Font& self, bool antialiased) { + std::scoped_lock lock{self.mutex}; + self.draw_glyphs_to_bitmap(antialiased); + }, py::kw_only(), "antialiased"_a=true, PyFT2Font_draw_glyphs_to_bitmap__doc__); // The generated docstring uses an unqualified "Buffer" as type hint, From 1a7d5229ef63f1825b76b441a7621aea68e60c47 Mon Sep 17 00:00:00 2001 From: Charlie Lin Date: Wed, 22 Jul 2026 16:20:36 -0400 Subject: [PATCH 11/11] Revert "Mutex around usages of global FT_Library variable" This reverts commit 61ff82281070a9cca1e022532cac24b3b06df521. --- src/ft2font.cpp | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/ft2font.cpp b/src/ft2font.cpp index c82fd0622cae..3e53a87343a6 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -7,8 +7,6 @@ #include #include #include -#include -#include #include #include #include @@ -20,22 +18,9 @@ FT_Library _ft2Library; -// FreeType is not thread-safe when a single FT_Library is shared across -// threads: FT_Open_Face and FT_Done_Face mutate the library's internal list of -// faces. The extension modules declare py::mod_gil_not_used(), so these can run -// concurrently under free-threading; serialize them with this mutex. Per-face -// glyph loading and bitmap conversion only use the (malloc-based) memory -// manager and are deliberately left unguarded to preserve render parallelism. -static std::mutex _ft2LibraryMutex; - FT2Image::FT2Image(unsigned long width, unsigned long height) - // calloc(width, height) performs the width*height overflow check internally - // and returns nullptr on overflow or allocation failure. - : m_buffer((unsigned char *)calloc(width, height)), m_width(width), m_height(height) + : m_buffer((unsigned char *)calloc(width * height, 1)), m_width(width), m_height(height) { - if (!m_buffer && width != 0 && height != 0) { - throw std::bad_alloc(); - } } FT2Image::~FT2Image() @@ -212,11 +197,7 @@ FT2Font::~FT2Font() void FT2Font::open(FT_Open_Args &open_args, FT_Long face_index) { - { - // FT_Open_Face mutates the shared _ft2Library; serialize it. - std::scoped_lock lock{_ft2LibraryMutex}; - FT_CHECK(FT_Open_Face, _ft2Library, &open_args, face_index, &face); - } + FT_CHECK(FT_Open_Face, _ft2Library, &open_args, face_index, &face); if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } @@ -239,8 +220,6 @@ void FT2Font::close() glyphs.clear(); if (face) { - // FT_Done_Face mutates the shared _ft2Library; serialize it. - std::scoped_lock lock{_ft2LibraryMutex}; FT_Done_Face(face); face = nullptr; }