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 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 17:37:56 -04:00
co-authored by Claude Opus 5.5
parent 048a709a5e
commit 7cee46926d
2 changed files with 22 additions and 4 deletions
+8 -4
View File
@@ -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 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. size need ``realised_px``, not the size they asked for.
Faces are cached and shared: a ``freetype.Face`` holds per-glyph state, Faces are cached per thread. A ``freetype.Face`` holds per-glyph state
so treat one as owned by the thread that draws. Raises if the file can't (``load_char`` rewrites its glyph slot), and FreeType does not allow two
be loaded at either size. 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: if freetype is None:
raise RuntimeError("freetype-py is not installed; BDF fonts need it") 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) abs_path = os.path.abspath(path)
try: try:
st = os.stat(abs_path) 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: except OSError:
key = None # let freetype raise its own error below key = None # let freetype raise its own error below
+14
View File
@@ -263,6 +263,20 @@ def test_cache_is_bounded(monkeypatch):
bdf_font.clear_face_cache() 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(): def test_native_size_prefers_pixel_size_over_point_size():
# 6x13.bdf is defined at 75dpi: SIZE says 12 (points), PIXEL_SIZE 13. # 6x13.bdf is defined at 75dpi: SIZE says 12 (points), PIXEL_SIZE 13.
assert read_bdf_native_size(str(FONTS_DIR / "6x13.bdf")) == 13 assert read_bdf_native_size(str(FONTS_DIR / "6x13.bdf")) == 13