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/BackendOpenGL.py, src/silx/gui/plot/backends/glutils/GLPlotItem.py, src/silx/gui/plot/backends/glutils/GLPlotTriangles.py, src/silx/gui/plot/backends/glutils/GLPlotCurve.py, src/silx/gui/plot/backends/glutils/GLPlotImage.py

diff --git a/src/silx/gui/plot/backends/BackendOpenGL.py b/src/silx/gui/plot/backends/BackendOpenGL.py
index 909d18ae1..6fde9df5c 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
@@ -990,7 +990,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
                                item.getYAxis() == 'right')
                 self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None
 
-            self._glGarbageCollector.append(item)
+            if item.isInitialized():
+                self._glGarbageCollector.append(item)
 
         elif isinstance(item, (_MarkerItem, _ShapeItem)):
             pass  # No-op
diff --git a/src/silx/gui/plot/backends/glutils/GLPlotCurve.py b/src/silx/gui/plot/backends/glutils/GLPlotCurve.py
index c4e2c1e2c..34844c613 100644
--- a/src/silx/gui/plot/backends/glutils/GLPlotCurve.py
+++ b/src/silx/gui/plot/backends/glutils/GLPlotCurve.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
@@ -235,12 +235,15 @@ class _Fill2D(object):
 
     def discard(self):
         """Release VBOs"""
-        if self._xFillVboData is not None:
+        if self.isInitialized():
             self._xFillVboData.vbo.discard()
 
         self._xFillVboData = None
         self._yFillVboData = None
 
+    def isInitialized(self):
+        return self._xFillVboData is not None
+
 
 # line ########################################################################
 
@@ -1061,13 +1064,16 @@ class _ErrorBars(object):
 
     def discard(self):
         """Release VBOs"""
-        if self._attribs is not None:
+        if self.isInitialized():
             self._lines.xVboData, self._lines.yVboData = None, None
             self._xErrPoints.xVboData, self._xErrPoints.yVboData = None, None
             self._yErrPoints.xVboData, self._yErrPoints.yVboData = None, None
             self._attribs[0].vbo.discard()
             self._attribs = None
 
+    def isInitialized(self):
+        return self._attribs is not None
+
 
 # curves ######################################################################
 
@@ -1272,6 +1278,11 @@ class GLPlotCurve2D(GLPlotItem):
         if self.fill is not None:
             self.fill.discard()
 
+    def isInitialized(self):
+        return (self.xVboData is not None or
+                self._errorBars.isInitialized() or
+                (self.fill is not None and self.fill.isInitialized()))
+
     def pick(self, xPickMin, yPickMin, xPickMax, yPickMax):
         """Perform picking on the curve according to its rendering.
 
diff --git a/src/silx/gui/plot/backends/glutils/GLPlotImage.py b/src/silx/gui/plot/backends/glutils/GLPlotImage.py
index f60a159a4..3ad94b9a8 100644
--- a/src/silx/gui/plot/backends/glutils/GLPlotImage.py
+++ b/src/silx/gui/plot/backends/glutils/GLPlotImage.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
@@ -286,6 +286,10 @@ class GLPlotColormap(_GLPlotData2D):
             self._texture = None
         self._textureIsDirty = False
 
+    def isInitialized(self):
+        return (self._cmap_texture is not None or
+                self._texture is not None)
+
     @property
     def cmapRange(self):
         if self.normalization == 'log':
@@ -622,11 +626,14 @@ class GLPlotRGBAImage(_GLPlotData2D):
         return self._alpha
 
     def discard(self):
-        if self._texture is not None:
+        if self.isInitialized():
             self._texture.discard()
             self._texture = None
         self._textureIsDirty = False
 
+    def isInitialized(self):
+        return self._texture is not None
+
     def updateData(self, data):
         assert data.dtype in self._SUPPORTED_DTYPES
         oldData = self.data
diff --git a/src/silx/gui/plot/backends/glutils/GLPlotItem.py b/src/silx/gui/plot/backends/glutils/GLPlotItem.py
index 899f38e25..ae13091d3 100644
--- a/src/silx/gui/plot/backends/glutils/GLPlotItem.py
+++ b/src/silx/gui/plot/backends/glutils/GLPlotItem.py
@@ -1,7 +1,7 @@
 # coding: utf-8
 # /*##########################################################################
 #
-# Copyright (c) 2020 European Synchrotron Radiation Facility
+# Copyright (c) 2020-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
@@ -92,3 +92,8 @@ class GLPlotItem:
     def discard(self):
         """Discards OpenGL resources this item has created."""
         pass
+
+    def isInitialized(self) -> bool:
+        """Returns True if resources where initialized and requires `discard`.
+        """
+        return True
diff --git a/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py b/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py
index d5ba1a65d..fbe9e027d 100644
--- a/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py
+++ b/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py
@@ -1,7 +1,7 @@
 # coding: utf-8
 # /*##########################################################################
 #
-# Copyright (c) 2019-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2019-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
@@ -144,12 +144,15 @@ class GLPlotTriangles(GLPlotItem):
 
     def discard(self):
         """Release resources on the GPU"""
-        if self.__vbos is not None:
+        if self.isInitialized():
             self.__vbos[0].vbo.discard()
             self.__vbos = None
             self.__indicesVbo.discard()
             self.__indicesVbo = None
 
+    def isInitialized(self):
+        return self.__vbos is not None
+
     def prepare(self):
         """Allocate resources on the GPU"""
         if self.__vbos is None: