From b2d5a3628339d68419721ba590def78c3c7b2343 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 11 Jul 2026 20:37:32 +0200 Subject: [PATCH 1/3] Garbage collect the text cache periodically so unique cell texts do not grow memory without bound The TextCache interns every unique multi-codepoint cell text for the lifetime of the window with no eviction, so a stream of unique texts (for example random combining mark sequences) grows memory without bound, several hundred MB/hour at moderate throughput. Mirror the existing hyperlink pool garbage collection: every 8192 newly interned entries, steal the cache contents and have the Screen remap the index in every live cell (history buffer, main and alt line buffers, paused rendering snapshot and overlay line), re-interning only entries still referenced by some cell. Entries whose last reference scrolled out of the history buffer are freed. See #10249 Co-Authored-By: Claude --- kitty/screen.c | 50 ++++++++++++++++++++++++++++++++++ kitty/screen.h | 1 + kitty/text-cache.c | 63 ++++++++++++++++++++++++++++++++++++++++++- kitty/text-cache.h | 12 +++++++++ kitty_tests/screen.py | 37 +++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 1 deletion(-) diff --git a/kitty/screen.c b/kitty/screen.c index e340a2a4a..a673a227a 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -905,8 +905,48 @@ set_active_hyperlink(Screen *self, char *id, char *url) { } } +static void +text_cache_gc_process_cells(TextCache *tc, TextCacheGCData *gc, CPUCell *cells, size_t count) { + for (size_t i = 0; i < count; i++) { + CPUCell *c = cells + i; + if (!c->ch_is_idx) continue; + char_type new_idx; + if (tc_gc_map_index(tc, gc, c->ch_or_idx, &new_idx)) c->ch_or_idx = new_idx; + else cell_set_char(c, 0); // stale index, should not happen + } +} + +static void +text_cache_gc_process_linebuf(TextCache *tc, TextCacheGCData *gc, LineBuf *lb) { + if (lb) text_cache_gc_process_cells(tc, gc, lb->cpu_cell_buf, (size_t)lb->ynum * lb->xnum); +} + +void +screen_garbage_collect_text_cache(Screen *self) { + // The TextCache interns unique cell texts forever; remap every live cell + // index onto a fresh cache so entries that scrolled out of the history + // buffer are freed. Mirrors screen_garbage_collect_hyperlink_pool(). + TextCacheGCData *gc = tc_gc_begin(self->text_cache); + if (!gc) return; // allocation failure, cache left unchanged + if (self->historybuf->count) { + for (index_type y = self->historybuf->count; y-- > 0;) { + CPUCell *cells = historybuf_cpu_cells(self->historybuf, y); + text_cache_gc_process_cells(self->text_cache, gc, cells, self->historybuf->xnum); + } + } + text_cache_gc_process_linebuf(self->text_cache, gc, self->main_linebuf); + text_cache_gc_process_linebuf(self->text_cache, gc, self->alt_linebuf); + text_cache_gc_process_linebuf(self->text_cache, gc, self->paused_rendering.linebuf); + if (self->overlay_line.cpu_cells) text_cache_gc_process_cells( + self->text_cache, gc, self->overlay_line.cpu_cells, self->overlay_line.xnum); + if (self->overlay_line.original_line.cpu_cells) text_cache_gc_process_cells( + self->text_cache, gc, self->overlay_line.original_line.cpu_cells, self->overlay_line.xnum); + tc_gc_end(gc); +} + static bool add_combining_char(Screen *self, char_type ch, index_type x, index_type y) { + if (tc_should_gc(self->text_cache)) screen_garbage_collect_text_cache(self); CPUCell *cpu_cells = linebuf_cpu_cells_for_line(self->linebuf, y); CPUCell *cell = cpu_cells + x; if (!cell_has_text(cell) || (cell->is_multicell && cell->y)) return false; // don't allow adding combining chars to a null cell @@ -1282,6 +1322,7 @@ handle_fixed_width_multicell_command(Screen *self, CPUCell mcd, ListOfChars *lc) lc->count = MIN(lc->count, MAX_NUM_CODEPOINTS_PER_CELL); PREPARE_FOR_DRAW_TEXT; mcd.hyperlink_id = s.cc.hyperlink_id; + if (tc_should_gc(self->text_cache)) screen_garbage_collect_text_cache(self); cell_set_chars(&mcd, self->text_cache, lc); move_cursor_past_multicell(self, width); if (height > 1) { @@ -2031,6 +2072,7 @@ screen_tab(Screen *self) { cell_set_char(c, ' '); } self->lc->count = 2; self->lc->chars[0] = '\t'; self->lc->chars[1] = diff; + if (tc_should_gc(self->text_cache)) screen_garbage_collect_text_cache(self); cell_set_chars(cpu_cell, self->text_cache, self->lc); } } @@ -4454,6 +4496,12 @@ update_overlay_line_data(Screen *self, uint8_t *data) { #define WRAP2B(name) static PyObject* name(Screen *self, PyObject *args) { unsigned int a, b; int p; if(!PyArg_ParseTuple(args, "IIp", &a, &b, &p)) return NULL; screen_##name(self, a, b, (bool)p); Py_RETURN_NONE; } WRAP0(garbage_collect_hyperlink_pool) +WRAP0(garbage_collect_text_cache) + +static PyObject* +text_cache_count(Screen *self, PyObject *a UNUSED) { + return PyLong_FromUnsignedLong((unsigned long)tc_num_entries(self->text_cache)); +} static PyObject* has_selection(Screen *self, PyObject *a UNUSED) { @@ -6267,6 +6315,8 @@ static PyMethodDef methods[] = { MND(scroll_until_cursor_prompt, METH_VARARGS) MND(hyperlinks_as_set, METH_NOARGS) MND(garbage_collect_hyperlink_pool, METH_NOARGS) + MND(garbage_collect_text_cache, METH_NOARGS) + MND(text_cache_count, METH_NOARGS) MND(hyperlink_for_id, METH_O) MND(reverse_scroll, METH_VARARGS) MND(scroll_prompt_to_bottom, METH_NOARGS) diff --git a/kitty/screen.h b/kitty/screen.h index 6124fedf1..964fabfc5 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -217,6 +217,7 @@ typedef struct { #define render_lines_for_screen(screen) (screen->lines + pixel_scroll_enabled(screen)) void screen_align(Screen*); +void screen_garbage_collect_text_cache(Screen *screen); void screen_restore_cursor(Screen *); void screen_save_cursor(Screen *); void screen_restore_modes(Screen *); diff --git a/kitty/text-cache.c b/kitty/text-cache.c index 8e8ebe755..a450f1a40 100644 --- a/kitty/text-cache.c +++ b/kitty/text-cache.c @@ -31,6 +31,7 @@ typedef struct TextCache { chars_map map; unsigned refcnt; CharsMonotonicArena arena; + unsigned adds_since_last_gc; } TextCache; static uint64_t hash_chars(Chars k) { return vt_hash_bytes(k.chars, sizeof(k.chars[0]) * k.count); } static bool cmpr_chars(Chars a, Chars b) { return a.count == b.count && memcmp(a.chars, b.chars, sizeof(a.chars[0]) * a.count) == 0; } @@ -144,6 +145,66 @@ char_type tc_get_or_insert_chars(TextCache *self, const ListOfChars *chars) { Chars key = {.count=chars->count, .chars=chars->chars}; chars_map_itr i = vt_get(&self->map, key); - if (vt_is_end(i)) return copy_and_insert(self, key); + if (vt_is_end(i)) { self->adds_since_last_gc++; return copy_and_insert(self, key); } return i.data->val; } + +char_type +tc_num_entries(const TextCache *self) { return self->array.count; } + +// Interned cell texts are referenced from cells by index, so entries cannot +// be evicted individually. Instead, periodically garbage collect: the owner +// of all index-holding cells (Screen) calls tc_gc_begin(), remaps every live +// cell index via tc_gc_map_index() -- which re-interns just the entries that +// are still referenced -- and finishes with tc_gc_end(). Entries no longer +// referenced by any cell (typically unique texts that have scrolled out of +// the history buffer) are freed. Without this, a stream of unique +// multi-codepoint cells (for example random combining marks) grows the cache +// without bound for the lifetime of the window. +#define TEXT_CACHE_ADDS_BETWEEN_GCS 8192u + +bool +tc_should_gc(const TextCache *self) { return self->adds_since_last_gc > TEXT_CACHE_ADDS_BETWEEN_GCS; } + +struct TextCacheGCData { + Chars *old_items; char_type old_count; + CharsMonotonicArena old_arena; + // old index -> new index + 1, 0 means not yet remapped + char_type *map; +}; + +TextCacheGCData* +tc_gc_begin(TextCache *self) { + TextCacheGCData *gc = calloc(1, sizeof(TextCacheGCData)); + if (!gc) return NULL; + gc->map = calloc(MAX(1u, (size_t)self->array.count), sizeof(gc->map[0])); + Chars *fresh = malloc(256 * sizeof(self->array.items[0])); + if (!gc->map || !fresh) { free(gc->map); free(fresh); free(gc); return NULL; } + gc->old_items = self->array.items; gc->old_count = self->array.count; + gc->old_arena = self->arena; + self->array.items = fresh; self->array.capacity = 256; self->array.count = 0; + zero_at_ptr(&self->arena); + vt_cleanup(&self->map); vt_init(&self->map); + self->adds_since_last_gc = 0; + return gc; +} + +bool +tc_gc_map_index(TextCache *self, TextCacheGCData *gc, char_type old_idx, char_type *new_idx) { + if (old_idx >= gc->old_count) return false; + if (!gc->map[old_idx]) { + Chars key = gc->old_items[old_idx]; + chars_map_itr i = vt_get(&self->map, key); + char_type nidx = vt_is_end(i) ? copy_and_insert(self, key) : i.data->val; + gc->map[old_idx] = nidx + 1; + } + *new_idx = gc->map[old_idx] - 1; + return true; +} + +void +tc_gc_end(TextCacheGCData *gc) { + free(gc->map); free(gc->old_items); + Chars_free_all(&gc->old_arena); + free(gc); +} diff --git a/kitty/text-cache.h b/kitty/text-cache.h index 81de0f4cd..7a33e8edf 100644 --- a/kitty/text-cache.h +++ b/kitty/text-cache.h @@ -51,6 +51,18 @@ TextCache* tc_decref(TextCache *self); void tc_chars_at_index(const TextCache *self, char_type idx, ListOfChars *ans); unsigned tc_chars_at_index_ansi(const TextCache *self, char_type idx, ANSIBuf *output); char_type tc_get_or_insert_chars(TextCache *self, const ListOfChars *chars); +char_type tc_num_entries(const TextCache *self); + +// Garbage collection: TextCache interns unique cell texts forever, so a +// stream of unique multi-codepoint cells grows it without bound. The GC +// mirrors the hyperlink pool design: steal the current entries, then have +// the owner (Screen) remap every live cell index via tc_gc_map_index(), +// which re-interns only referenced entries into the fresh cache. +bool tc_should_gc(const TextCache *self); +typedef struct TextCacheGCData TextCacheGCData; +TextCacheGCData* tc_gc_begin(TextCache *self); +bool tc_gc_map_index(TextCache *self, TextCacheGCData *gc, char_type old_idx, char_type *new_idx); +void tc_gc_end(TextCacheGCData *gc); char_type tc_first_char_at_index(const TextCache *self, char_type idx); char_type tc_last_char_at_index(const TextCache *self, char_type idx); bool tc_chars_at_index_without_alloc(const TextCache *self, char_type idx, ListOfChars *ans); diff --git a/kitty_tests/screen.py b/kitty_tests/screen.py index 25d94e3f1..f3d8f9647 100644 --- a/kitty_tests/screen.py +++ b/kitty_tests/screen.py @@ -1204,6 +1204,43 @@ class TestScreen(BaseTest): self.ae('2', s.hyperlink_at(1, 3)) self.ae(s.current_url_text(), 'Z Z') + def test_text_cache_garbage_collection(self): + # unique multi-codepoint cell texts, single width base + combining mark + def unique_text(i): + return chr(0x100 + i // 0x70) + chr(0x300 + i % 0x70) + + s = self.create_screen() + base = s.text_cache_count() + for i in range(10): + s.draw(unique_text(i)) + self.ae(s.text_cache_count(), base + 10) + before = tuple(str(s.line(y)) for y in range(s.lines)) + s.garbage_collect_text_cache() + # all entries are still referenced by cells, so all survive + self.ae(s.text_cache_count(), base + 10) + self.ae(before, tuple(str(s.line(y)) for y in range(s.lines))) + + # scroll all multi-codepoint cells out of the screen and the history + # buffer, then intern one more entry, which gets a high index + for i in range(s.lines * 3): + s.linefeed() + s.carriage_return() + s.draw(unique_text(10)) + s.garbage_collect_text_cache() + # only the surviving entry remains and its index was remapped + # without changing the cell's text + self.ae(s.text_cache_count(), base + 1) + self.ae(str(s.line(s.cursor.y)).rstrip(), unique_text(10)) + + # the periodic GC keeps the cache bounded when unique cell texts + # are continuously created and scrolled out, as in the DoS scenario + s = self.create_screen() + num = 3 * 8192 + 100 + for i in range(num): + s.draw(unique_text(i)) + self.assertLess(s.text_cache_count(), 8192 + 2 * s.lines * s.columns) + self.ae(str(s.line(s.cursor.y)).rstrip()[-2:], unique_text(num - 1)) + def test_bottom_margin(self): s = self.create_screen(cols=80, lines=6, scrollback=4) s.set_margins(0, 5) From b8da20444e42d36656d9aa2835508c17cb373dc1 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sun, 12 Jul 2026 15:27:24 +0200 Subject: [PATCH 2/3] Run TextCache GC before drawing text Move the periodic collection check to PREPARE_FOR_DRAW_TEXT so ordinary and fixed-width text share one safe integration point, while tab handling no longer initiates collection. Reset the additions counter only after the live-cell remap completes successfully. Co-Authored-By: Claude --- kitty/screen.c | 6 ++---- kitty/text-cache.c | 4 ++-- kitty/text-cache.h | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/kitty/screen.c b/kitty/screen.c index a673a227a..cf5fff944 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -941,12 +941,11 @@ screen_garbage_collect_text_cache(Screen *self) { self->text_cache, gc, self->overlay_line.cpu_cells, self->overlay_line.xnum); if (self->overlay_line.original_line.cpu_cells) text_cache_gc_process_cells( self->text_cache, gc, self->overlay_line.original_line.cpu_cells, self->overlay_line.xnum); - tc_gc_end(gc); + tc_gc_end(self->text_cache, gc); } static bool add_combining_char(Screen *self, char_type ch, index_type x, index_type y) { - if (tc_should_gc(self->text_cache)) screen_garbage_collect_text_cache(self); CPUCell *cpu_cells = linebuf_cpu_cells_for_line(self->linebuf, y); CPUCell *cell = cpu_cells + x; if (!cell_has_text(cell) || (cell->is_multicell && cell->y)) return false; // don't allow adding combining chars to a null cell @@ -1252,6 +1251,7 @@ draw_text_loop(Screen *self, const uint32_t *chars, size_t num_chars, text_loop_ } #define PREPARE_FOR_DRAW_TEXT \ + if (tc_should_gc(self->text_cache)) screen_garbage_collect_text_cache(self); \ const bool force_underline = OPT(underline_hyperlinks) == UNDERLINE_ALWAYS && self->active_hyperlink_id != 0; \ CellAttrs attrs = cursor_to_attrs(self->cursor); \ if (force_underline) attrs.decoration = OPT(url_style); \ @@ -1322,7 +1322,6 @@ handle_fixed_width_multicell_command(Screen *self, CPUCell mcd, ListOfChars *lc) lc->count = MIN(lc->count, MAX_NUM_CODEPOINTS_PER_CELL); PREPARE_FOR_DRAW_TEXT; mcd.hyperlink_id = s.cc.hyperlink_id; - if (tc_should_gc(self->text_cache)) screen_garbage_collect_text_cache(self); cell_set_chars(&mcd, self->text_cache, lc); move_cursor_past_multicell(self, width); if (height > 1) { @@ -2072,7 +2071,6 @@ screen_tab(Screen *self) { cell_set_char(c, ' '); } self->lc->count = 2; self->lc->chars[0] = '\t'; self->lc->chars[1] = diff; - if (tc_should_gc(self->text_cache)) screen_garbage_collect_text_cache(self); cell_set_chars(cpu_cell, self->text_cache, self->lc); } } diff --git a/kitty/text-cache.c b/kitty/text-cache.c index a450f1a40..6203928cd 100644 --- a/kitty/text-cache.c +++ b/kitty/text-cache.c @@ -185,7 +185,6 @@ tc_gc_begin(TextCache *self) { self->array.items = fresh; self->array.capacity = 256; self->array.count = 0; zero_at_ptr(&self->arena); vt_cleanup(&self->map); vt_init(&self->map); - self->adds_since_last_gc = 0; return gc; } @@ -203,7 +202,8 @@ tc_gc_map_index(TextCache *self, TextCacheGCData *gc, char_type old_idx, char_ty } void -tc_gc_end(TextCacheGCData *gc) { +tc_gc_end(TextCache *self, TextCacheGCData *gc) { + self->adds_since_last_gc = 0; free(gc->map); free(gc->old_items); Chars_free_all(&gc->old_arena); free(gc); diff --git a/kitty/text-cache.h b/kitty/text-cache.h index 7a33e8edf..742c2b1f1 100644 --- a/kitty/text-cache.h +++ b/kitty/text-cache.h @@ -62,7 +62,7 @@ bool tc_should_gc(const TextCache *self); typedef struct TextCacheGCData TextCacheGCData; TextCacheGCData* tc_gc_begin(TextCache *self); bool tc_gc_map_index(TextCache *self, TextCacheGCData *gc, char_type old_idx, char_type *new_idx); -void tc_gc_end(TextCacheGCData *gc); +void tc_gc_end(TextCache *self, TextCacheGCData *gc); char_type tc_first_char_at_index(const TextCache *self, char_type idx); char_type tc_last_char_at_index(const TextCache *self, char_type idx); bool tc_chars_at_index_without_alloc(const TextCache *self, char_type idx, ListOfChars *ans); From ed51a3e97cc185caacb2e3948e8fcf3d37923bf4 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sun, 12 Jul 2026 15:27:24 +0200 Subject: [PATCH 3/3] Benchmark unique multi-codepoint Unicode cells Add a benchmark stream containing 262,144 distinct base-plus-combining-mark cells per repetition so TextCache collection cost remains visible instead of collapsing into cache hits. Co-Authored-By: Claude --- tools/cmd/benchmark/main.go | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tools/cmd/benchmark/main.go b/tools/cmd/benchmark/main.go index 6ef8e78b1..77aab1663 100644 --- a/tools/cmd/benchmark/main.go +++ b/tools/cmd/benchmark/main.go @@ -150,6 +150,27 @@ func unicode() (r result, err error) { return result{desc, data_sz, duration, reps}, nil } +func unique_unicode() (r result, err error) { + const cell_count = 256 * 1024 + const combining_count = 0x70 + var data strings.Builder + data.Grow(cell_count * 10) + for i := range cell_count { + q := i + data.WriteByte('a') + for range 3 { + data.WriteRune(rune(0x300 + q%combining_count)) + q /= combining_count + } + } + const desc = "Unique multi-codepoint Unicode cells" + duration, data_sz, reps, err := benchmark_data(desc, data.String(), opts) + if err != nil { + return result{}, err + } + return result{desc, data_sz, duration, reps}, nil +} + func ascii_with_csi() (r result, err error) { const sz = 1024*1024 + 17 out := make([]byte, 0, sz+48) @@ -244,7 +265,7 @@ func present_result(r result, col_width int) { func all_benchamrks() []string { return []string{ - "ascii", "unicode", "csi", "images", "long_escape_codes", + "ascii", "unicode", "unique_unicode", "csi", "images", "long_escape_codes", } } @@ -276,6 +297,13 @@ func main(args []string) (err error) { results = append(results, r) } + if slices.Index(args, "unique_unicode") >= 0 { + if r, err = unique_unicode(); err != nil { + return err + } + results = append(results, r) + } + if slices.Index(args, "csi") >= 0 { if r, err = ascii_with_csi(); err != nil { return err