You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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
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:
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).
importgcimportsysfromtextual.appimportAppfromtextual.screenimportModalScreen, Screenfromtextual.stripimportStripfromtextual.widgetsimportLabel, LoadingIndicatorsamples= []
classBusy(Screen):
defcompose(self):
yieldLoadingIndicator()
classDialog(ModalScreen):
defcompose(self):
yieldLabel("A modal over a screen that keeps redrawing")
classLeakApp(App):
defon_mount(self):
self.push_screen(Busy())
self.push_screen(Dialog())
self.set_interval(10, self.sample)
defsample(self):
strips=sum(1forobjingc.get_objects() iftype(obj) isStrip)
samples.append(f"{len(samples) *10:3d}s live Strips: {strips}")
iflen(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:
importgcfromrich.segmentimportSegmentfromtextual.stripimportStripgc.collect()
gc.disable()
strip=Strip([Segment("foo")])
strip.divide([3])
delstripprint(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
github-actions commentedon Oct 15, 2026
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