From 7cee46926dbf581b9020109e513f8a2d48490da5 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:37:56 -0400 Subject: [PATCH] fix(fonts): one BDF face per thread The shared face cache now hands every loader (FontManager, element_style, DisplayManager, the harness) the same freetype.Face. FreeType does not allow two threads to use one face at once, since load_char rewrites its glyph slot, so key the cache by thread as well. Co-Authored-By: Claude Opus 5.5 --- src/common/bdf_font.py | 12 ++++++++---- test/test_bdf_font.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/common/bdf_font.py b/src/common/bdf_font.py index f77cf1cc..a792c3ab 100644 --- a/src/common/bdf_font.py +++ b/src/common/bdf_font.py @@ -101,9 +101,12 @@ def load_bdf_face(path: str, size_px: int) -> Tuple[Any, int]: font that was asked for at the size it can do. Callers that lay out by size need ``realised_px``, not the size they asked for. - Faces are cached and shared: a ``freetype.Face`` holds per-glyph state, - so treat one as owned by the thread that draws. Raises if the file can't - be loaded at either size. + Faces are cached per thread. A ``freetype.Face`` holds per-glyph state + (``load_char`` rewrites its glyph slot), and FreeType does not allow two + threads to use one face at once, so the display thread and a plugin's + update thread must never be handed the same object. Within a thread the + face is shared by every caller. Raises if the file can't be loaded at + either size. """ if freetype is None: raise RuntimeError("freetype-py is not installed; BDF fonts need it") @@ -111,7 +114,8 @@ def load_bdf_face(path: str, size_px: int) -> Tuple[Any, int]: abs_path = os.path.abspath(path) try: st = os.stat(abs_path) - key = (abs_path, size_px, st.st_mtime_ns, st.st_size) + key = (threading.get_ident(), abs_path, size_px, + st.st_mtime_ns, st.st_size) except OSError: key = None # let freetype raise its own error below diff --git a/test/test_bdf_font.py b/test/test_bdf_font.py index 1142a658..9a8bda48 100644 --- a/test/test_bdf_font.py +++ b/test/test_bdf_font.py @@ -263,6 +263,20 @@ def test_cache_is_bounded(monkeypatch): bdf_font.clear_face_cache() +def test_each_thread_gets_its_own_face(): + # FreeType forbids two threads using one face at once: load_char rewrites + # the face's glyph slot. Within a thread the face is shared. + import threading + path = str(FONTS_DIR / "5x7.bdf") + here, _ = load_bdf_face(path, 7) + assert load_bdf_face(path, 7)[0] is here + other = [] + worker = threading.Thread(target=lambda: other.append(load_bdf_face(path, 7)[0])) + worker.start() + worker.join() + assert other and other[0] is not here + + def test_native_size_prefers_pixel_size_over_point_size(): # 6x13.bdf is defined at 75dpi: SIZE says 12 (points), PIXEL_SIZE 13. assert read_bdf_native_size(str(FONTS_DIR / "6x13.bdf")) == 13