From 39cdba439c1ae47dbc7c3243a9273aa011c6affa Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 24 Sep 2026 12:22:51 -0300 Subject: Allocate FreeType's and HarfBuzz's memory from the gui's Zig allocator FreeType and HarfBuzz allocated with libc malloc, out of sight of the Zig allocator the rest of the gui uses, so its Debug leak report and a test's std.testing.allocator never saw them. Now every allocation the font code makes goes through ui_malloc, ui_calloc, ui_realloc and ui_free, exported from gui.zig over `font_allocator`: - HarfBuzz is compiled with hb_malloc_impl and friends pointing at them. - Each UIFont gives its FreeType library its own memory manager (FT_New_Library with an FT_MemoryRec_ over the same functions, then FT_Add_Default_Modules and FT_Set_Default_Properties: exactly what FT_Init_FreeType does over its malloc manager), torn down with FT_Done_Library. - The shim's own UIFont and scratch arrays use them too. C's free and realloc pass no size, so a block starts with a 32-byte header (the caller's pointer stays 16-byte, max_align_t, aligned) holding its size and the allocator that made it, so a block goes back where it came from even after font_allocator is repointed. runNative points font_allocator at the gui's gpa. HarfBuzz makes a few process-wide objects on first use and never frees them (the default Unicode functions, the locale's language, hb-ft's font functions); ui_font_prime makes them from the default heap before that, so a Debug build's leak report stays about fonts. Tests do the same: the shaping tests, and a new test that creates, rasterizes and shapes fonts under std.testing.allocator, report any leak. Only the render thread calls into fonts, and every allocator used is thread-safe. It costs nothing measurable: against the previous change, 10 interleaved rounds with an A/A pair show every scenario as fast or faster with Adwaita Mono and Maple Mono alike, and first paint over 30 launches each matches main. Co-Authored-By: Claude Opus 5.5 (1M context) --- build.zig | 8 ++++- src/gui/font.c | 59 ++++++++++++++++++++++++++++-------- src/gui/font.h | 16 ++++++++++ src/gui/gui.zig | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 162 insertions(+), 14 deletions(-) diff --git a/build.zig b/build.zig index 21df1555..c490cbac 100644 --- a/build.zig +++ b/build.zig @@ -644,7 +644,9 @@ pub fn build(b: *std.Build) void { gm.linkLibrary(freetype_dep.artifact("freetype")); // HarfBuzz from source, like FreeType: its FreeType integration // (HAVE_FREETYPE, hb-ft) links the freetype artifact above, so the two - // share one FreeType. The flags are the ones Ghostty builds it with. + // share one FreeType. The flags are the ones Ghostty builds it with, + // plus its allocator: gui.zig's ui_malloc and friends, over a Zig + // allocator, as FreeType's is. if (b.lazyDependency("harfbuzz", .{})) |upstream| { const harfbuzz = b.addLibrary(.{ .name = "harfbuzz", @@ -670,6 +672,10 @@ pub fn build(b: *std.Build) void { "-DHAVE_FT_SET_VAR_BLEND_COORDINATES=1", "-DHAVE_FT_DONE_MM_VAR=1", "-DHAVE_FT_GET_TRANSFORM=1", + "-Dhb_malloc_impl=ui_malloc", + "-Dhb_calloc_impl=ui_calloc", + "-Dhb_realloc_impl=ui_realloc", + "-Dhb_free_impl=ui_free", }, }); gm.linkLibrary(harfbuzz); diff --git a/src/gui/font.c b/src/gui/font.c index 5cb98e1a..aebe26e6 100644 --- a/src/gui/font.c +++ b/src/gui/font.c @@ -6,14 +6,17 @@ #include "font.h" #include #include +#include #include #include #include -#include #include struct UIFont { + // FreeType allocates through ui_malloc and friends: the library holds a + // pointer to this for its lifetime, which the UIFont outlives. + struct FT_MemoryRec_ memory; FT_Library library; FT_Face face; FT_F26Dot6 size; @@ -60,20 +63,42 @@ static FT_Int32 load_flags(void) { return FT_LOAD_DEFAULT | FT_LOAD_TARGET_LIGHT; } +static void *ft_alloc(FT_Memory memory, long size) { + (void)memory; + return ui_malloc((size_t)size); +} + +static void ft_free(FT_Memory memory, void *block) { + (void)memory; + ui_free(block); +} + +static void *ft_realloc(FT_Memory memory, long cur_size, long new_size, + void *block) { + (void)memory; + (void)cur_size; + return ui_realloc(block, (size_t)new_size); +} + UIFont *ui_font_new(const uint8_t *data, int32_t len) { if (!data || len <= 0) return NULL; - UIFont *f = (UIFont *)calloc(1, sizeof(UIFont)); + UIFont *f = (UIFont *)ui_calloc(1, sizeof(UIFont)); if (!f) return NULL; - if (FT_Init_FreeType(&f->library) != 0) { - free(f); + // FT_Init_FreeType is exactly FT_New_Library over FT_New_Memory's malloc + // manager, then the default modules and properties: the same, over ours. + f->memory = (struct FT_MemoryRec_){NULL, ft_alloc, ft_free, ft_realloc}; + if (FT_New_Library(&f->memory, &f->library) != 0) { + ui_free(f); return NULL; } + FT_Add_Default_Modules(f->library); + FT_Set_Default_Properties(f->library); if (FT_New_Memory_Face(f->library, data, (FT_Long)len, 0, &f->face) != 0) { - FT_Done_FreeType(f->library); - free(f); + FT_Done_Library(f->library); + ui_free(f); return NULL; } // Some symbol fonts do not expose a Unicode charmap. FreeType already @@ -136,10 +161,20 @@ void ui_font_free(UIFont *f) { if (f->face) FT_Done_Face(f->face); if (f->library) - FT_Done_FreeType(f->library); - free(f->own); - free(f->state); - free(f); + FT_Done_Library(f->library); + ui_free(f->own); + ui_free(f->state); + ui_free(f); +} + +void ui_font_prime(UIFont *f) { + if (!f) + return; + hb_font_destroy(hb_ft_font_create_referenced(f->face)); + hb_buffer_t *buffer = hb_buffer_create(); + hb_buffer_add_utf8(buffer, "a", 1, 0, 1); + hb_buffer_guess_segment_properties(buffer); + hb_buffer_destroy(buffer); } void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h, @@ -325,11 +360,11 @@ void ui_font_shape(UIFont *f, float px, int32_t cell_w, const uint32_t *cps, if (!substitutable || cell_w <= 0 || !set_size(f, px)) return; if (n > f->scratch) { - uint32_t *own = (uint32_t *)realloc(f->own, (size_t)n * sizeof *own); + uint32_t *own = (uint32_t *)ui_realloc(f->own, (size_t)n * sizeof *own); if (!own) return; f->own = own; - uint8_t *state = (uint8_t *)realloc(f->state, (size_t)n); + uint8_t *state = (uint8_t *)ui_realloc(f->state, (size_t)n); if (!state) return; f->state = state; diff --git a/src/gui/font.h b/src/gui/font.h index b38f822a..db964fb0 100644 --- a/src/gui/font.h +++ b/src/gui/font.h @@ -4,6 +4,7 @@ // for the face lifetime. #ifndef UI_FONT_H #define UI_FONT_H +#include #include #ifdef __cplusplus @@ -12,6 +13,15 @@ extern "C" { typedef struct UIFont UIFont; +// Every allocation FreeType, HarfBuzz and this shim make: C's malloc, calloc, +// realloc and free, implemented by gui.zig over a Zig allocator. HarfBuzz is +// compiled to call them (hb_malloc_impl and friends); FreeType gets them as +// each library's memory manager. +void *ui_malloc(size_t size); +void *ui_calloc(size_t count, size_t size); +void *ui_realloc(void *block, size_t size); +void ui_free(void *block); + // Parse the first face in an in-memory font. `data` must stay alive for the // lifetime of the font. Returns NULL on failure. UIFont *ui_font_new(const uint8_t *data, int32_t len); @@ -76,6 +86,12 @@ int ui_font_raster_centered(UIFont *f, float px, uint32_t glyph, int bold, void ui_font_free(UIFont *f); +// HarfBuzz makes a few process-wide objects on first use and keeps them for +// the life of the process (the default Unicode functions, the locale's +// language, hb-ft's font functions). Making them here, with `f`'s face, lets +// the caller hand them an allocator that does not count them as leaks. +void ui_font_prime(UIFont *f); + #ifdef __cplusplus } #endif diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 5bcb2867..a4cc7672 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -133,6 +133,85 @@ fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u .height = @intCast(band_h), }; } +/// What FreeType and HarfBuzz allocate from, through ui_malloc and friends +/// below. runNative points it at the gui's gpa once HarfBuzz's process-wide +/// objects exist (ui_font_prime), so a Debug build's leak report is about +/// fonts; a test that counts what fonts leak points it at +/// std.testing.allocator the same way. Only the render thread calls into +/// fonts, and every allocator it is pointed at is thread-safe anyway. +var font_allocator: std.mem.Allocator = std.heap.smp_allocator; + +/// C's free and realloc pass no size, so a block starts with its size and the +/// allocator that made it: a block made before font_allocator was repointed +/// goes back where it came from. 32 bytes keep the caller's pointer 16-byte +/// (max_align_t) aligned. +const FontBlock = struct { size: usize, allocator: std.mem.Allocator }; +const font_block_header = 32; +comptime { + std.debug.assert(@sizeOf(FontBlock) <= font_block_header); +} + +export fn ui_malloc(size: usize) ?*anyopaque { + const total = std.math.add(usize, font_block_header, size) catch return null; + const bytes = font_allocator.alignedAlloc(u8, .@"16", total) catch return null; + @as(*FontBlock, @ptrCast(bytes.ptr)).* = .{ .size = size, .allocator = font_allocator }; + return bytes.ptr + font_block_header; +} + +export fn ui_calloc(count: usize, size: usize) ?*anyopaque { + const total = std.math.mul(usize, count, size) catch return null; + const block: [*]u8 = @ptrCast(ui_malloc(total) orelse return null); + @memset(block[0..total], 0); + return block; +} + +export fn ui_realloc(block: ?*anyopaque, size: usize) ?*anyopaque { + const old = block orelse return ui_malloc(size); + const base: [*]align(16) u8 = @alignCast(@as([*]u8, @ptrCast(old)) - font_block_header); + const header: *FontBlock = @ptrCast(base); + const total = std.math.add(usize, font_block_header, size) catch return null; + const bytes = header.allocator.realloc(base[0 .. font_block_header + header.size], total) catch return null; + @as(*FontBlock, @ptrCast(bytes.ptr)).size = size; + return bytes.ptr + font_block_header; +} + +export fn ui_free(block: ?*anyopaque) void { + const old = block orelse return; + const base: [*]align(16) u8 = @alignCast(@as([*]u8, @ptrCast(old)) - font_block_header); + const header: *FontBlock = @ptrCast(base); + header.allocator.free(base[0 .. font_block_header + header.size]); +} + +test "FreeType and HarfBuzz allocate from the gui allocator and give it all back" { + // HarfBuzz's process-wide objects come from the default heap, as in runNative. + const primer = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; + c.ui_font_prime(primer); + c.ui_font_free(primer); + font_allocator = std.testing.allocator; + defer font_allocator = std.heap.smp_allocator; + + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; + defer c.ui_font_free(font); + var mask: [64 * 64]u8 = undefined; + try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, 27, c.ui_font_glyph(font, 'g'), 1, &mask, 64, 32, 48, 0, 32, 30)); + try std.testing.expectEqual(@as(c_int, 0), c.ui_font_substitutes(font, c.ui_font_glyph(font, '-'))); + + // A font with ligatures runs the whole HarfBuzz pass. + const bytes = filesystem.readFile(std.testing.allocator, "assets/MapleMono-NF-Regular.ttf") catch return; + defer std.testing.allocator.free(bytes); + const maple = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; + defer c.ui_font_free(maple); + var cell_w: c_int = 0; + var cell_h: c_int = 0; + var ascent: c_int = 0; + c.ui_font_cell_metrics(maple, 27, &cell_w, &cell_h, &ascent); + const text = [_]u32{ 'a', '-', '>', 'b', '=', '=' }; + var shaped: [text.len]c.UIShapedCell = undefined; + c.ui_font_shape(maple, 27, cell_w, &text, text.len, &shaped); + try std.testing.expectEqual(@as(u8, 2), shaped[1].span); + try std.testing.expectEqual(@as(u8, 2), shaped[4].span); +} + const max_fallback_fonts = 1 + fonts.fallback_names.len; const LoadedFallback = struct { face: *c.UIFont, @@ -2181,10 +2260,16 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u else c.SDL_GetGPUSwapchainTextureFormat(device, window); + font_allocator = gpa; const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; }; + // HarfBuzz's process-wide objects are made once and never freed: made + // from the default heap, they stay out of a Debug build's leak report. + font_allocator = std.heap.smp_allocator; + c.ui_font_prime(font); + font_allocator = gpa; const px: f32 = 27.0; var cw: c_int = 10; var chh: c_int = 20; @@ -6106,8 +6191,12 @@ fn cellSlot(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: return words.slots.items[text_start + at - start]; } -/// A Gui that can shape and rasterize `font` at `px`, and nothing else. +/// A Gui that can shape and rasterize `font` at `px`, and nothing else. From +/// here on the fonts allocate from std.testing.allocator (the caller puts +/// font_allocator back), so the test counts what they leak. fn textTestGui(font: *c.UIFont, px: f32) !Gui { + c.ui_font_prime(font); + font_allocator = std.testing.allocator; var g: Gui = undefined; g.font = font; g.fallback_count = 0; @@ -6141,6 +6230,7 @@ test "a ligature draws across its cells, one slice each, and comes apart under t const font = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(font); var g = try textTestGui(font, 27); + defer font_allocator = std.heap.smp_allocator; defer std.testing.allocator.free(g.atlas_stage); defer g.glyphs.deinit(); defer g.words.deinit(std.testing.allocator); @@ -6199,6 +6289,7 @@ test "a font without ligatures draws every cell as its own glyph, exactly as bef const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); var g = try textTestGui(font, 18); + defer font_allocator = std.heap.smp_allocator; defer std.testing.allocator.free(g.atlas_stage); defer g.glyphs.deinit(); defer g.words.deinit(std.testing.allocator); -- cgit v1.3