Skip to content

[BUG] Strip.divide caches a list containing the strip itself, a reference cycle per divided line; under a translucent modal on Python 3.14 the app's memory grows without bound #6738

Open

Description

Have you checked closed issues? Yes — nothing on divide or reference cycles in Strip.
Have you checked against the most recent version of Textual? Yes — 8.2.8, and main (06dbeef4b) has the same code.

The bug

Strip.divide() caches its result in the strip's own _divide_cache. When the only cut is the strip's full width it returns [self], so the strip ends up holding a list that holds the strip:

if cuts == [cell_length]:
    strips = [self]
...
self._divide_cache[cache_key] = strips

Every such strip is a reference cycle and can only be freed by the cyclic garbage collector. The compositor divides each rendered line, so any freshly rendered full-width line becomes one of these cycles.

A translucent ModalScreen (the default, background: $background 60%) renders the screen beneath it through BackgroundScreen on every refresh, so while the screen beneath keeps redrawing, the app makes one cycle per line per frame. Python 3.13's generational collector keeps up. Python 3.14's incremental collector does not: in a long-running app (a modal left over a screen with a running indicator) we measured the process grow to 7.4 GB anon in 14 h 48 min, until the kernel OOM-killed it. A forced gc.collect() frees it.

The example below counts live Strip objects every 10 s and prints them on exit (about 70 s).

import gc
import sys

from textual.app import App
from textual.screen import ModalScreen, Screen
from textual.strip import Strip
from textual.widgets import Label, LoadingIndicator

samples = []


class Busy(Screen):
    def compose(self):
        yield LoadingIndicator()


class Dialog(ModalScreen):
    def compose(self):
        yield Label("A modal over a screen that keeps redrawing")


class LeakApp(App):
    def on_mount(self):
        self.push_screen(Busy())
        self.push_screen(Dialog())
        self.set_interval(10, self.sample)

    def sample(self):
        strips = sum(1 for obj in gc.get_objects() if type(obj) is Strip)
        samples.append(f"{len(samples) * 10:3d}s  live Strips: {strips}")
        if len(samples) == 7:
            self.exit()


LeakApp().run()
print(sys.version.split()[0])
print("\n".join(samples))
0 s 10 s 20 s 30 s 40 s 50 s 60 s
Python 3.13.7, Textual 8.2.8 769 1,124 1,479 982 556 911 1,266
Python 3.14.4, Textual 8.2.8 7,301 11,762 13,223 13,770 13,478 13,495 13,649
Python 3.14.4, divide not caching [self] 178 178 178 178 178 178 178

With the collector disabled, the cycle shows directly:

import gc
from rich.segment import Segment
from textual.strip import Strip

gc.collect()
gc.disable()
strip = Strip([Segment("foo")])
strip.divide([3])
del strip
print(gc.collect())  # 13 on Python 3.13, 18 on 3.14; 0 when [self] is not cached

Proposed fix

Return [self] for a single full-width cut without caching it. The cache only ever saved building a one-item list. I have this with a regression test (tests/test_strip.py) that fails today and passes with the change, and can open the PR if the approach is OK.

Environment: Textual 8.2.8; the example on macOS 15 with Python 3.13.7 and 3.14.4; the 7.4 GB process on Ubuntu 26.04 (Linux 7.0) with Python 3.14.4.

This issue was written with an AI agent (Claude Code, Claude Opus 5.5) and checked by me.

Activity

  1. github-actions commented on Oct 15, 2026

    @github-actions

    We found the following entries in the FAQ which you may find helpful:

    Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

    This project is developed and maintained by Will McGugan. Consider sponsoring Will's work on this project (and others).

    This is an automated reply, generated by FAQtory

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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