diff --git a/kitty/screen.c b/kitty/screen.c index e340a2a4a..cf5fff944 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -905,6 +905,45 @@ 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(self->text_cache, gc); +} + static bool add_combining_char(Screen *self, char_type ch, index_type x, index_type y) { CPUCell *cpu_cells = linebuf_cpu_cells_for_line(self->linebuf, y); @@ -1212,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); \ @@ -4454,6 +4494,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 +6313,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..6203928cd 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); + 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(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 81de0f4cd..742c2b1f1 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(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); 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) 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