Skip to content

Memory leak — watchers from self.watch(other, ...) / data_bind are not removed when the owner is removed from the DOM #6666

Description

@cdxjcl

The bug

If a short-lived widget does self.watch(some_long_lived_object, "attr", ...) — e.g.
self.watch(self.app, "theme", ...) — the widget and its whole subtree are never released after
the widget is removed, as long as the observed attribute does not change again.

The watcher entry (self, callback) is stored on the observed object's __watchers["attr"].
It is only pruned in Reactive._check_watchers, which runs when the observed reactive changes:

watchers[:] = [
    (reactable, callback)
    for reactable, callback in watchers
    if not reactable._closing
]

So if the observed attribute is stable, the watcher (and the widget it references) stays on the long-lived object forever.

Concrete example (Footer)

Footer.compose builds FooterKey widgets with .data_bind(compact=Footer.compact).
data_bind calls self.watch(Footer, "compact", ...), storing the entry (FooterKey, cb) on the
long-lived Footer
(the observed object), in Footer.__watchers["compact"]. So even after a
FooterKey is discarded, the entry that points back to it stays on Footer.

Footer.on_mount subscribes bindings_changed to Screen.bindings_updated_signal. That signal is
published by Screen.refresh_bindings(), which Reactive._set invokes whenever any reactive with
the bindings=True flag changes — including App.focused on every focus move. Crucially it fires
regardless of whether the binding set actually differs, so bindings_changed runs recompose on
every focus/screen change (this is exactly why a debounce guard is needed to suppress no-op recomposes).

recompose discards the old FooterKeys and builds new ones, each registering a fresh
(FooterKey, cb) entry on Footer.__watchers["compact"]. Since Footer.compact itself rarely
changes, that watcher list grows by one stale FooterKey per bindings_changed, leaking every old
FooterKey (and its Label/Strip subtree) on the long-lived Footer.

Minimal example

from textual.app import App, ComposeResult
from textual.widgets import Footer, Label

class LeakApp(App):
    BINDINGS = [("a", "dummy", "do a"), ("b", "dummy", "do b")]

    def compose(self) -> ComposeResult:
        yield Label("focus me")
        yield Footer()

    def on_mount(self) -> None:
        self.set_interval(0.1, self.cycle_focus)

    def cycle_focus(self) -> None:
        # Focusing between widgets triggers App.focused (a bindings=True reactive) to
        # change, which publishes Screen.bindings_updated_signal -> Footer.bindings_changed
        # -> recompose, registering a new FooterKey watcher each time; old FooterKey
        # objects are never released (compact rarely changes, so __watchers is never pruned).
        label = self.query_one(Label)
        label.focus()
        self.screen.focus_next()

if __name__ == "__main__":
    LeakApp().run()

To observe the leak, instrument the live Footer (e.g. print
len(self.query_one(Footer).__watchers["compact"]) each cycle) — it grows without bound while RSS
climbs.

Expected behavior

When a reactable is removed/unmounted, proactively drop its watcher entries from the observed object (e.g. in remove()/unmount), instead of relying solely on the observed value changing.

Also, it might be worth considering exposing a public remove_watch API. Currently, watch() returns None, leaving no way for an app to surgically unregister a single cross-object watcher.

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