13 KiB
APP-4098 — CJK fallback font memory growth on Windows
Linear: https://linear.app/warpdotdev/issue/APP-4098/cjk-fallback-font
Problem
On Windows, rendering many unique CJK codepoints into a block (e.g. a long stream of Chinese output) causes Warp's memory to grow without bound and never be released. When the user's primary font already contains the CJK glyphs, the bug does not reproduce. The growth is caused by the per-character font-fallback path duplicating a large fallback font file (typically Microsoft YaHei, a 10–25 MB TTC) into fontdb once per unique codepoint.
This spec covers the technical fix landed on branch kc/memory2.
Relevant code
app/src/terminal/grid_renderer/cell_glyph_cache.rs:32—CellGlyphCache::glyph_for_charcallsFontCache::glyph_for_char(font_id, ch, /* include_fallback_fonts */ true)once per (char, font) for every terminal cell.crates/warpui_core/src/fonts.rs:457-500—Cache::glyph_for_charcaches the finalOption<(GlyphId, FontId)>per(FontId, char)inglyphs_by_char. On a miss it first tries app-specified fallbacks, thensystem_font_fallback, which callsplatform.fallback_fonts(ch, font).crates/warpui/src/windowing/winit/fonts.rs—TextLayoutSystem(cosmic_text + fontdb wrapper). Containsloaded_fonts: DashMap<FontKey, FontId>used byinsert_fontfor path-based dedup, whereFontKeyis(PathBuf, u32).crates/warpui/src/windowing/winit/fonts/windows.rs:125-265—TextLayoutSystem::get_fallback_fonts_for_characterplus the newfallback_font_path_handlehelper.- External:
font-kit/src/loader.rs:172-176— defaultLoader::handle()impl that reads the full font file into anArc<Vec<u8>>and returnsHandle::Memory(bytes, 0). Used by the DirectWriteFontin the absence of a platform-specific override. This is what we step around. - External:
font-kit/src/sources/directwrite.rs:103-110—DirectWriteSource::create_handle_from_dwrite_fontalready builds aHandle::Pathfor enumerated system fonts. Our helper mirrors this pattern. - External:
dwrote/src/font_face.rs—FontFace::files(),FontFace::get_index().dwrote/src/font_file.rs—FontFile::font_file_path(). These are the DirectWrite accessors we use to rebuild a Path handle.
Current state (before this fix)
On Windows, fallback resolution is lazy and per-character:
CellGlyphCache::glyph_for_charentersCache::glyph_for_char(font_id, ch, true)for each cell.- For characters not present in the primary font,
Cache::glyph_for_charfalls back tosystem_font_fallback(ch, font)→FontDB::fallback_fonts(ch, font)→TextLayoutSystem::get_fallback_fonts_for_character(ch, font_id). - That function uses font-kit (
SystemSource+Font::get_fallbacks) to ask DirectWrite which font provides the glyph. For each fallbackfont_kit::Font, it used to call.handle(), pass the handle toload_font_from_handle, and finallyinsert_fontto register the face in the cosmic_textFontSystem's fontdb. Font::handle()on DirectWrite uses the defaultLoader::handleimpl, which reads the full font file into memory and returnsHandle::Memory(Arc<Vec<u8>>, /* font_index */ 0). Thefont_index = 0is also wrong for font collections (there's a FIXME to this effect in font-kit itself), and the bytes are a fresh allocation every call.load_font_from_handlepattern-matches onHandle::Memoryand copies the bytes (bytes.to_vec()) into anOwnedFace.insert_fontwraps that intofontdb::Source::Binary(Arc::new(face.into_vec()))and callsfontdb.load_font_source(source). The dedup cacheloaded_fonts: DashMap<FontKey, FontId>is keyed byPathBuf, soSource::Binaryentries bypass it entirely. BecauseCache::glyphs_by_charcaches by(FontId, char), each unique CJK codepoint triggers the full pipeline exactly once. With N unique codepoints, fontdb ends up with N duplicate copies of the same fallback face retained for the life of theFontSystem. When the user's primary font already contains the CJK glyphs,Cache::glyph_for_charreturns atcrates/warpui_core/src/fonts.rs:471without ever entering the fallback path, so the bug is invisible — matching the user-reported symptom.
Design
The fix addresses the root cause: Font::handle() returning Handle::Memory instead of Handle::Path. Rather than caching around the byte-copy pipeline, we skip it entirely by reaching through font-kit's NativeFont to the underlying IDWriteFontFace and rebuilding a path-based handle ourselves — the same trick DirectWriteSource::create_handle_from_dwrite_font uses for enumerated system fonts.
New helper in windows.rs
fn fallback_font_path_handle(font: &font_kit::loaders::directwrite::Font) -> Option<FontHandle> {
let native = font.native_font();
let file = native.dwrite_font_face.files().ok()?.into_iter().next()?;
let path = file.font_file_path().ok()?;
let font_index = native.dwrite_font_face.get_index();
Some(FontHandle::new(path, font_index, font.is_monospace()))
}
Returns None for fonts that are not backed by a local file (custom-collection loaders, in-memory streams). IDWriteFontFallback::MapCharacters against the system font collection always produces file-backed faces, so this branch is taken ~100% of the time for real CJK fallbacks.
Modified flow in get_fallback_fonts_for_character
For each FallbackFont returned by Font::get_fallbacks:
- Try
fallback_font_path_handle(&fallback_font.font). On success — almost always — we get aFontHandle::Path { path, index, is_monospace }with no byte copy. - On
None, fall back to the legacy.handle()→load_font_from_handlepath, which still returns a correct handle viaHandle::Memoryfor custom-collection fonts. This keeps behavior for edge cases identical to today. - Pass the handle to
self.insert_font(...). For path handles, the existingloaded_fonts: DashMap<FontKey, FontId>dedup fires onFontKey { path, index }and returns the previously loadedFontIdwithout touching fontdb. For the rare memory fallback, behavior is unchanged. No new caches or structs are added toTextLayoutSystem. The earlier design's Windows-gatedloaded_fallback_fonts_by_ps_name: DashMap<String, FontId>is no longer needed — the existing path-based dedup does the job.
Cargo.toml
Add dwrote as a direct Windows dependency pinned to the same git rev the workspace already uses transitively via font-kit (warpdotdev/dwrote-rs @ a71ce6c0). This keeps the compiled version graph flat.
[target.'cfg(target_os = "windows")'.dependencies]
dwrote = { git = "https://github.com/warpdotdev/dwrote-rs", rev = "a71ce6c0136f7d0954a9a8b181b5b2d8ace5eb9c", default-features = false }
Why this design
- Fixes the root cause. The leak is that
.handle()returns Memory on Windows. We no longer call.handle()on the hot path, so the problem is excised at the source rather than papered over with a cache. - Zero new state. No new
DashMaponTextLayoutSystem, no new lock, no new lifecycle concerns. All dedup flows through the existingloaded_fontsDashMap, which is already how enumerated system fonts are deduped today. - TTC-correct. We pass the real
dwrite_font_face.get_index()instead of hard-coded0, so if DirectWrite picks face 3 out of a TTC we actually load face 3. This also fixes the silent correctness bug called out by the FIXME in font-kit's defaultLoader::handle. - mmap-friendly. fontdb sees
Source::File(path), andcosmic_text::FontSystem::get_fontcallsdb.make_shared_face_data(id)to mmap the file on demand. Multiple faces from the same TTC share one mapping, pages are lazy-loaded from disk, and no anonymous heap allocation is needed to hold the font data. - Matches existing pattern.
DirectWriteSource::create_handle_from_dwrite_fontalready does this for enumerated fonts. We're just applying the same pattern on the fallback path. - Doesn't require a font-kit fork change. We can ship the fix in
warp-internaltoday. A follow-up to overrideLoader::handle()inside the font-kit fork is still desirable (so other consumers benefit too), but is not a blocker.
End-to-end flow
sequenceDiagram
participant Grid as CellGlyphCache
participant Cache as warpui_core::fonts::Cache
participant FontDB as winit::fonts::FontDB
participant TLS as TextLayoutSystem
participant FK as font_kit (DirectWrite)
participant DW as dwrote (IDWriteFontFace)
participant FdB as fontdb
Grid->>Cache: glyph_for_char(font, '中', include_fallback=true)
alt glyphs_by_char hit
Cache-->>Grid: cached (glyph_id, font_id)
else glyphs_by_char miss
Cache->>FontDB: fallback_fonts('中', font)
FontDB->>TLS: get_fallback_fonts_for_character('中', font)
TLS->>FK: FKSource::select_best_match + Font::get_fallbacks('中')
FK-->>TLS: FallbackFont { font: YaHei, .. }
TLS->>DW: native_font().dwrite_font_face.files() + get_index()
DW-->>TLS: FontFile + face_index
TLS->>DW: font_file_path()
DW-->>TLS: PathBuf
TLS->>TLS: FontHandle::Path { path, index, is_monospace }
TLS->>FdB: insert_font
alt loaded_fonts hit (same path+index already loaded)
FdB-->>TLS: existing FontId
else loaded_fonts miss
FdB->>FdB: load_font_source(Source::File(path))
FdB-->>TLS: new fontdb::ID
end
TLS-->>Cache: [FontId]
Cache->>Cache: glyphs_by_char.insert((font,'中'), (glyph_id, FontId))
Cache-->>Grid: (glyph_id, font_id)
end
Every call after the first for the same fallback face hits loaded_fonts and returns the existing FontId immediately. fontdb never holds more than one entry per (path, index) pair.
Risks and mitigations
font_file_path()returnsErr. Happens only for fonts not backed by a local file (custom collection loaders, in-memory streams). DirectWrite system fallbacks always have a path, so this case is not exercised for real CJK fallbacks. We gracefully fall back to the legacy Memory-handle path, so behavior for edge cases is strictly no worse than before.- Extra COM calls per character. Each unique-codepoint first lookup now makes two additional COM calls (
GetFilesand the local-loader cast infont_file_path()). Both are cheap and only happen once per unique codepoint thanks to the existingglyphs_by_charcache inwarpui_core::fonts::Cache. - Pinned dwrote version. We now carry
dwroteas a direct dep pinned to a specific git rev. If font-kit's transitive pin drifts, we could end up with two compiled versions. Mitigation: the fork pins are both underwarpdotdev/, and CI'scargo tree -p dwroteoutput will show a dup immediately. Bump both at once. - font-kit fork drift. None. We do not modify
warpdotdev/font-kit. If/when we later overrideLoader::handle()in the fork, the in-tree helper simply becomes redundant and can be removed without behavior change. - Concurrency. Unchanged. All dedup flows through
loaded_fonts, which is aDashMap— same as today.
Testing and validation
- Manual repro:
- On Windows, select a primary font with no CJK glyphs (e.g. Cascadia Code NF).
catorGet-Contenta large text file containing many unique Chinese characters.- Observe Warp's working-set memory in Task Manager. Before: grows with unique-codepoint count and does not return after the block scrolls off. After: plateaus after the first fallback family loads, then stays flat.
- Static checks (already run on the implementation):
cargo check -p warpui --target x86_64-pc-windows-msvc,cargo clippy -p warpui --target x86_64-pc-windows-msvc --no-deps,cargo fmt -p warpui -- --check. - Automated regression (not landed): a reliable unit/integration test would need to exercise the real DirectWrite system-font set, which is environment-dependent (CI images may not even ship CJK fonts). Deferred in favor of the manual repro; see Follow-ups.
Follow-ups
- Patch
warpdotdev/font-kitto overrideLoader::handle()on the DirectWrite loader so it returnsHandle::Path { path, font_index }whendwrite_font_face.files()[0].font_file_path()succeeds (mirroringDirectWriteSource::create_handle_from_dwrite_fontinsrc/sources/directwrite.rs). Also fix the hard-codedfont_index = 0in the defaulthandle()impl. Once that lands,fallback_font_path_handlein our code can be deleted and we can simply callfont.handle()everywhere. - Remove the throwaway
font_handle.load()?inload_font_from_handlewhenValidateFontSupportsEn::Nois passed (the call exists only for theis_monospaceside-effect, which the caller already has viafont.is_monospace()). - Consider layering a
(FontId, char) → Vec<FontId>cache analogous to the non-Windowsfallback_fontsDashMap so we can skip re-entering font-kit/DirectWrite even for cold characters. Not required for the memory fix; pure perf optimization on top of the existingglyphs_by_charcache. - Add an integration-style test that stands up a
TextLayoutSystemwith a stub fontdb pre-seeded with a known CJK face and asserts thatfallback_fontsreturns the sameFontIdacross many distinct characters. Requires some test-only injection hooks intoTextLayoutSystem; not worth the churn as part of this fix but a sensible follow-up.