Merge branch 'fix/text-cache-gc' of https://github.com/Sinity/kitty
Some checks are pending
CI / Linux (python=3.14 cc=clang sanitize=1) (push) Waiting to run
CI / Linux (python=3.12 cc=gcc sanitize=0) (push) Waiting to run
CI / Linux (python=3.13 cc=gcc sanitize=1) (push) Waiting to run
CI / Linux package (push) Waiting to run
CI / Bundle test (macos-latest) (push) Waiting to run
CI / Bundle test (ubuntu-latest) (push) Waiting to run
CI / macOS Brew (push) Waiting to run
CI / Test ./dev.sh and benchmark (push) Waiting to run
CodeQL / CodeQL-Build (actions, ubuntu-latest) (push) Waiting to run
CodeQL / CodeQL-Build (c, macos-latest) (push) Waiting to run
CodeQL / CodeQL-Build (c, ubuntu-latest) (push) Waiting to run
CodeQL / CodeQL-Build (go, ubuntu-latest) (push) Waiting to run
CodeQL / CodeQL-Build (python, ubuntu-latest) (push) Waiting to run
Depscan / Scan dependencies for vulnerabilities (push) Waiting to run

This commit is contained in:
Kovid Goyal 2026-07-12 20:07:45 +05:30
commit 7ab90de166
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
6 changed files with 189 additions and 2 deletions

View file

@ -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)

View file

@ -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 *);

View file

@ -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);
}

View file

@ -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);

View file

@ -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)

View file

@ -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