render: bound the blank entries in the glyph caches #450

Merged
natsukium merged 3 commits from fix/glyph-406 into main 2026-09-25 13:37:43 +09:00
Owner

Closes #406

Two client-side glyph caches grew without bound on glyphs that rasterize blank, whose keys PTY output chooses (char × OSC 66 sizing × style).

  • shaping: ShapeCache counted only pixel bytes against its 16 MiB cap, and a blank bitmap has none. Each entry now also costs its key and bitmap header (size_of::<(key, GlyphBitmap)>()), so blanks count and are evicted like any other entry.
  • render: GlyphIndex::slots cached blanks, and glyphs too large for an empty sheet, as None. Only an atlas recycle cleared the map, and a None takes no atlas space. The index now counts None inserts, and past BLANK_SLOT_MAX (4096) it retains only the placed slots. Lookups treat a missing key and a blank one the same way, so a sweep cannot change a frame, even for rows the incremental walk skips.

Hit paths (ensure, ShapeCache::get_or_insert_sized) are unchanged: the new work runs only on a miss, so no Criterion run was needed.

Tests written first and failing before the fix:

  • glyphs::tests::distinct_blank_keys_keep_the_slot_map_under_the_blank_cap (had 4097 entries at the first overflow)
  • glyphs::tests::dropping_blank_entries_keeps_placed_slots_and_the_atlas
  • tests::blank_bitmaps_count_against_the_byte_cap (felis-shaping; had 2048 blank entries under a 4 KiB cap)

Docs: text-shaping.md "Cache", pipeline.md "Atlases", security-model.md "Resource exhaustion", REQ-1010, CHANGELOG (Fixed).

nix develop -c just check passes. The row_cache.rs file is not touched.

Closes #406 Two client-side glyph caches grew without bound on glyphs that rasterize blank, whose keys PTY output chooses (char × OSC 66 sizing × style). - **shaping**: `ShapeCache` counted only pixel bytes against its 16 MiB cap, and a blank bitmap has none. Each entry now also costs its key and bitmap header (`size_of::<(key, GlyphBitmap)>()`), so blanks count and are evicted like any other entry. - **render**: `GlyphIndex::slots` cached blanks, and glyphs too large for an empty sheet, as `None`. Only an atlas recycle cleared the map, and a `None` takes no atlas space. The index now counts `None` inserts, and past `BLANK_SLOT_MAX` (4096) it `retain`s only the placed slots. Lookups treat a missing key and a blank one the same way, so a sweep cannot change a frame, even for rows the incremental walk skips. Hit paths (`ensure`, `ShapeCache::get_or_insert_sized`) are unchanged: the new work runs only on a miss, so no Criterion run was needed. Tests written first and failing before the fix: - `glyphs::tests::distinct_blank_keys_keep_the_slot_map_under_the_blank_cap` (had 4097 entries at the first overflow) - `glyphs::tests::dropping_blank_entries_keeps_placed_slots_and_the_atlas` - `tests::blank_bitmaps_count_against_the_byte_cap` (felis-shaping; had 2048 blank entries under a 4 KiB cap) Docs: text-shaping.md "Cache", pipeline.md "Atlases", security-model.md "Resource exhaustion", REQ-1010, CHANGELOG (Fixed). `nix develop -c just check` passes. The row_cache.rs file is not touched.
The cache evicted once the pixel bytes of its entries passed 16 MiB,
but a glyph that rasterizes blank (a space, U+2800, a charmap miss) has
no pixels, so blank entries never counted. PTY output picks the key:
OSC 66 alone allows about 1.3e5 sizings per char, times four styles,
so a producer printing blanks at walking sizings grew the map for the
life of the client.

Each entry now costs its key and bitmap header on top of its pixels.
That bounds blank entries to about three hundred thousand without a
second counter, and leaves the hit path untouched: only the miss path
computes the cost.

Assisted-by: Claude Code
The slot map caches a glyph that rasterizes blank as a `None` entry so
a screen of spaces does not rasterize every frame. Only an atlas
recycle clears the map, and a blank entry takes no atlas space, so it
never brings one on. PTY output picks the key (char, cell height, OSC
66 sizing, style), so each blank a producer displayed at a new sizing
stayed resident for the life of the client.

The map now counts its `None` entries and, past 4096, drops all of them
in one `retain`, keeping the placed slots and the atlas. Lookups read a
missing key and a blank one alike as no slot, so a sweep cannot change
a frame, even for rows the incremental walk skips. Dropping them all at
once keeps the per-insert cost a counter bump; evicting one at a time,
as the shaped-run memo does, needs a key scan per insert. Keying blanks
on the char alone was rejected: a glyph can be blank at one sizing and
not another (a tiny fractional scale), and a glyph too large for an
empty sheet is also cached as `None` by size.

The hit path in `ensure` is unchanged: the new work runs only when a
miss inserts a blank.

Assisted-by: Claude Code
docs: drop U+2800 from the shape-cache blank examples
All checks were successful
darwin / detect relevant changes (pull_request) Successful in 13s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 18s
windows / windows docs-only no-op (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 41s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m2s
pr / wire schema is compatible with the base (pull_request) Successful in 15s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 17s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 17s
windows / cargo nextest (Windows) (pull_request) Successful in 6m32s
windows / frontend smoke (Windows) (pull_request) Successful in 1m48s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
f0d7c8eb7a
U+2800 goes through the programmatic Braille rasterizer, never the
shape cache, and its all-zero bitmap does carry pixel bytes, so it
does not illustrate why a pixels-only budget leaves that cache
unbounded. It stays an example for the glyph slot map.

Assisted-by: Claude Code
natsukium deleted branch fix/glyph-406 2026-09-25 13:37:43 +09:00
Commenting is not possible because the repository is archived.
No description provided.