The bug
In a TUI that re-renders many widgets on scroll/refresh (a list that rebuilds ~40 cards each
time), RSS grows without bound — I measured 75 MB → 170 MB+ with no plateau.
The retained objects turn out to be Strip instances held by StylesCache. The ownership chain is:
orphaned Strip -> StylesCache -> functools._lru_cache_wrapper (key "get_inner_outer")
Minimal example
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import Label
class LeakApp(App):
def compose(self) -> ComposeResult:
yield Vertical(id="list")
def on_mount(self) -> None:
self.set_interval(0.1, self.rebuild)
def rebuild(self) -> None:
# Rebuild ~40 styled widgets every 100ms; old StylesCache objects are
# retained forever by lru_cache on StylesCache.get_inner_outer.
self.query_one("#list").remove_children()
self.query_one("#list").mount(*(Label(f"card {i}") for i in range(40)))
if __name__ == "__main__":
LeakApp().run()
Run it and watch RSS (e.g. textual diagnose + a memory profiler). It climbs without plateau;
the leaked objects are whole StylesCache instances pinned by the lru_cache.
Root cause
textual/_styles_cache.py decorates the instance method get_inner_outer with
@lru_cache(1024):
@lru_cache(1024)
def get_inner_outer(cls, base_background: Color, background: Color) -> tuple[Style, Style]:
return (
Style(background=base_background + background),
Style(background=base_background),
)
Because it is an instance method, calling self.get_inner_outer(a, b) passes the StylesCache
instance as cls. The function body only uses the two Color arguments and never cls/self.
So the lru_cache key includes the entire StylesCache object, which holds self._cache
(every rendered line's Strip), and each Strip carries 7 FIFOCache instances. Up to 1024
whole StylesCache objects are retained forever.
Expected behavior
The cache key should depend only on the two colors. Make it a staticmethod (or drop cls
from the signature). Minimal fix:
@staticmethod
@lru_cache(1024)
def get_inner_outer(base_background: Color, background: Color) -> tuple[Style, Style]:
...
Environment
- Textual: 8.2.8
- Rich: 15.0.0
- Python: 3.14.6 (CPython, MSC v.1944 64 bit AMD64)
- OS: Windows 11 (10.0.26200)
- Terminal: Windows Terminal
Textual Diagnostics
Versions
| Name |
Value |
| Textual |
8.2.8 |
| Rich |
15.0.0 |
Python
| Name |
Value |
| Version |
3.14.6 |
| Implementation |
CPython |
| Compiler |
MSC v.1944 64 bit (AMD64) |
| Executable |
C:\Users\foo\bar.venv\Scripts\python.exe |
Operating System
| Name |
Value |
| System |
Windows |
| Release |
11 |
| Version |
10.0.26200 |
Terminal
| Name |
Value |
| Terminal Application |
Windows Terminal |
| TERM |
Not set |
| COLORTERM |
Not set |
| FORCE_COLOR |
Not set |
| NO_COLOR |
Not set |
Rich Console options
| Name |
Value |
| size |
width=119, height=40 |
| legacy_windows |
False |
| min_width |
1 |
| max_width |
119 |
| is_terminal |
True |
| encoding |
utf-8 |
| max_height |
40 |
| justify |
None |
| overflow |
None |
| no_wrap |
False |
| highlight |
None |
| markup |
None |
| height |
None |
The bug
In a TUI that re-renders many widgets on scroll/refresh (a list that rebuilds ~40 cards each
time), RSS grows without bound — I measured 75 MB → 170 MB+ with no plateau.
The retained objects turn out to be
Stripinstances held byStylesCache. The ownership chain is:Minimal example
Run it and watch RSS (e.g.
textual diagnose+ a memory profiler). It climbs without plateau;the leaked objects are whole
StylesCacheinstances pinned by thelru_cache.Root cause
textual/_styles_cache.pydecorates the instance methodget_inner_outerwith@lru_cache(1024):Because it is an instance method, calling
self.get_inner_outer(a, b)passes theStylesCacheinstance as
cls. The function body only uses the twoColorarguments and nevercls/self.So the
lru_cachekey includes the entireStylesCacheobject, which holdsself._cache(every rendered line's
Strip), and eachStripcarries 7FIFOCacheinstances. Up to 1024whole
StylesCacheobjects are retained forever.Expected behavior
The cache key should depend only on the two colors. Make it a
staticmethod(or dropclsfrom the signature). Minimal fix:
Environment
Textual Diagnostics
Versions
Python
Operating System
Terminal
Rich Console options