Skip to content

Memory leak — StylesCache.get_inner_outer retains whole StylesCache objects via lru_cache #6665

Description

@cdxjcl

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions