Repository: silx-kit/silx
Issue: Memory leak on opengl backend plots

There is a lazy remove on the OpenGL backend in order to clean up OpenGL item in the right thread.

This code is executed during the `paintGL`.

If the plot is never displayed, items are never cleaned up.

But the plot can be updated with new items which will be never displayed.

As result the amount of items increase, until the plot is displayed again.

When using tabs, it's really common to have hidden plots.

----

With Flint, while the plot was on an hidden tab.

The plot is a curve plot with only 3 curves.

```
In [7]: len(w._silxPlot()._backend._glGarbageCollector)
Out[7]: 2190
```

After a display of this tab

```
In [8]: len(w._silxPlot()._backend._glGarbageCollector)
Out[8]: 0
```

----

A backport to 0.14 would be very welcome.

Changed files:
src/silx/gui/plot/backends/BackendMatplotlib.py, src/silx/gui/plot/backends/BackendBase.py, src/silx/gui/plot/PlotWidget.py, src/silx/gui/plot/backends/BackendOpenGL.py

diff --git a/src/silx/gui/plot/PlotWidget.py b/src/silx/gui/plot/PlotWidget.py
index 2a211de4a..15faef4d3 100755
--- a/src/silx/gui/plot/PlotWidget.py
+++ b/src/silx/gui/plot/PlotWidget.py
@@ -3128,15 +3128,23 @@ class PlotWidget(qt.QMainWindow):
         if self._autoreplot and self._getDirtyPlot():
             self._backend.postRedisplay()
 
-    def replot(self):
-        """Redraw the plot immediately."""
+    @contextmanager
+    def _paintContext(self):
+        """This context MUST surround backend rendering.
+
+        It is in charge of performing required PlotWidget operations
+        """
         for item in self._contentToUpdate:
             item._update(self._backend)
 
         self._contentToUpdate = []
-        self._backend.replot()
+        yield
         self._dirty = False  # reset dirty flag
 
+    def replot(self):
+        """Request to draw the plot."""
+        self._backend.replot()
+
     def _forceResetZoom(self, dataMargins=None):
         """Reset the plot limits to the bounds of the data and redraw the plot.
 
diff --git a/src/silx/gui/plot/backends/BackendBase.py b/src/silx/gui/plot/backends/BackendBase.py
index 6fc1aa72e..1e8680718 100755
--- a/src/silx/gui/plot/backends/BackendBase.py
+++ b/src/silx/gui/plot/backends/BackendBase.py
@@ -1,7 +1,7 @@
 # coding: utf-8
 # /*##########################################################################
 #
-# Copyright (c) 2004-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2021 European Synchrotron Radiation Facility
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to deal
@@ -328,23 +328,13 @@ class BackendBase(object):
         return None
 
     def postRedisplay(self):
-        """Trigger a :meth:`Plot.replot`.
-
-        Default implementation triggers a synchronous replot if plot is dirty.
-        This method should be overridden by the embedding widget in order to
-        provide an asynchronous call to replot in order to optimize the number
-        replot operations.
-        """
-        # This method can be deferred and it might happen that plot has been
-        # destroyed in between, especially with unittests
-
-        plot = self._plotRef()
-        if plot is not None and plot._getDirtyPlot():
-            plot.replot()
+        """Trigger backend update and repaint."""
+        self.replot()
 
     def replot(self):
         """Redraw the plot."""
-        pass
+        with self._plot._paintContext():
+            pass
 
     def saveGraph(self, fileName, fileFormat, dpi):
         """Save the graph to a file (or a StringIO)
diff --git a/src/silx/gui/plot/backends/BackendMatplotlib.py b/src/silx/gui/plot/backends/BackendMatplotlib.py
index 432b0b009..7fe4ec0bf 100755
--- a/src/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/src/silx/gui/plot/backends/BackendMatplotlib.py
@@ -994,6 +994,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
 
         Override in subclass to actually draw something.
         """
+        with self._plot._paintContext():
+            self._replot()
+
+    def _replot(self):
+        """Call from subclass :meth:`replot` to handle updates"""
         # TODO images, markers? scatter plot? move in remove?
         # Right Y axis only support curve for now
         # Hide right Y axis if no line is present
@@ -1342,8 +1347,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
 
         # Make postRedisplay asynchronous using Qt signal
         self._sigPostRedisplay.connect(
-            super(BackendMatplotlibQt, self).postRedisplay,
-            qt.Qt.QueuedConnection)
+            self.__deferredReplot, qt.Qt.QueuedConnection)
 
         self._picked = None
 
@@ -1355,6 +1359,14 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
     def postRedisplay(self):
         self._sigPostRedisplay.emit()
 
+    def __deferredReplot(self):
+        # Since this is deferred, makes sure it is still needed
+        plot = self._plotRef()
+        if (plot is not None and
+                plot._getDirtyPlot() and
+                plot.getBackend() is self):
+            self.replot()
+
     def _getDevicePixelRatio(self) -> float:
         """Compatibility wrapper for devicePixelRatioF"""
         if hasattr(self, 'devicePixelRatioF'):
@@ -1504,27 +1516,28 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
         self._drawOverlays()
 
     def replot(self):
-        BackendMatplotlib.replot(self)
-
-        dirtyFlag = self._plot._getDirtyPlot()
-
-        if dirtyFlag == 'overlay':
-            # Only redraw overlays using fast rendering path
-            if self._background is None:
-                self._background = self.copy_from_bbox(self.fig.bbox)
-            self.restore_region(self._background)
-            self._drawOverlays()
-            self.blit(self.fig.bbox)
-
-        elif dirtyFlag:  # Need full redraw
-            self.draw()
-
-        # Workaround issue of rendering overlays with some matplotlib versions
-        if (_parse_version('1.5') <= self._matplotlibVersion < _parse_version('2.1') and
-                not hasattr(self, '_firstReplot')):
-            self._firstReplot = False
-            if self._hasOverlays():
-                qt.QTimer.singleShot(0, self.draw)  # Request async draw
+        with self._plot._paintContext():
+            BackendMatplotlib._replot(self)
+
+            dirtyFlag = self._plot._getDirtyPlot()
+
+            if dirtyFlag == 'overlay':
+                # Only redraw overlays using fast rendering path
+                if self._background is None:
+                    self._background = self.copy_from_bbox(self.fig.bbox)
+                self.restore_region(self._background)
+                self._drawOverlays()
+                self.blit(self.fig.bbox)
+
+            elif dirtyFlag:  # Need full redraw
+                self.draw()
+
+            # Workaround issue of rendering overlays with some matplotlib versions
+            if (_parse_version('1.5') <= self._matplotlibVersion < _parse_version('2.1') and
+                    not hasattr(self, '_firstReplot')):
+                self._firstReplot = False
+                if self._hasOverlays():
+                    qt.QTimer.singleShot(0, self.draw)  # Request async draw
 
     # cursor
 
diff --git a/src/silx/gui/plot/backends/BackendOpenGL.py b/src/silx/gui/plot/backends/BackendOpenGL.py
index 909d18ae1..a6e5e2e02 100755
--- a/src/silx/gui/plot/backends/BackendOpenGL.py
+++ b/src/silx/gui/plot/backends/BackendOpenGL.py
@@ -1,7 +1,7 @@
 # coding: utf-8
 # /*##########################################################################
 #
-# Copyright (c) 2014-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2021 European Synchrotron Radiation Facility
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to deal
@@ -196,9 +196,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
     So, the caller should not modify these arrays afterwards.
     """
 
-    _sigPostRedisplay = qt.Signal()
-    """Signal handling automatic asynchronous replot"""
-
     def __init__(self, plot, parent=None, f=qt.Qt.WindowFlags()):
         glu.OpenGLWidget.__init__(self, parent,
                                   alphaBufferSize=8,
@@ -234,11 +231,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
             int(self.getDevicePixelRatio() * 640),
             int(self.getDevicePixelRatio() * 480))
 
-        # Make postRedisplay asynchronous using Qt signal
-        self._sigPostRedisplay.connect(
-            super(BackendOpenGL, self).postRedisplay,
-            qt.Qt.QueuedConnection)
-
         self.setAutoFillBackground(False)
         self.setMouseTracking(True)
 
@@ -386,23 +378,28 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
         self._renderOverlayGL()
 
     def paintGL(self):
-        with glu.Context.current(self.context()):
-            # Release OpenGL resources
-            for item in self._glGarbageCollector:
-                item.discard()
-            self._glGarbageCollector = []
+        plot = self._plotRef()
+        if plot is None:
+            return
 
-            gl.glClearColor(*self._backgroundColor)
-            gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT)
+        with plot._paintContext():
+            with glu.Context.current(self.context()):
+                # Release OpenGL resources
+                for item in self._glGarbageCollector:
+                    item.discard()
+                self._glGarbageCollector = []
+
+                gl.glClearColor(*self._backgroundColor)
+                gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT)
 
-            # Check if window is large enough
-            if self._plotFrame.plotSize <= (2, 2):
-                return
+                # Check if window is large enough
+                if self._plotFrame.plotSize <= (2, 2):
+                    return
 
-            # Sync plot frame with window
-            self._plotFrame.devicePixelRatio = self.getDevicePixelRatio()
-            # self._paintDirectGL()
-            self._paintFBOGL()
+                # Sync plot frame with window
+                self._plotFrame.devicePixelRatio = self.getDevicePixelRatio()
+                # self._paintDirectGL()
+                self._paintFBOGL()
 
     def _renderItems(self, overlay=False):
         """Render items according to :class:`PlotWidget` order
@@ -1162,11 +1159,10 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
         return self
 
     def postRedisplay(self):
-        self._sigPostRedisplay.emit()
+        self.update()
 
     def replot(self):
         self.update()  # async redraw
-        # self.repaint()  # immediate redraw
 
     def saveGraph(self, fileName, fileFormat, dpi):
         if dpi is not None: