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 <noreply@anthropic.com>
This commit is contained in:
Sinity
2026-07-11 20:37:32 +02:00
parent 2607d22ac3
commit b2d5a36283
5 changed files with 162 additions and 1 deletions

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)