Repository: pyqtgraph/pyqtgraph

Issue:
`QMenu`s not destroyed (memory leak)
I found, that when you repeatedly instantiate `PlotItem`s and delete them again, the corresponding `QMenu` objects for the context menus of the plots never get destroyed. I saw lot's of `QMenu` instances using GammaRay (https://www.kdab.com/development-resources/qt-tools/gammaray/)

But this can simply be reproduced with the following snippet. The more often you create, add and remove new plot instances the more memory is used. Simply increase `N` to e.g. 100, 500, 1000, 2000 and check the memory usage.

```
import pyqtgraph as pg

app = pg.mkQApp()
widget = pg.GraphicsLayoutWidget()
widget.show()

N = 10
for i in range(N):
    plot = pg.PlotItem()
    plot.plot(x=[0,i, N], y=[0.0, 1.0, 0.0])
    widget.addItem(plot)
    app.processEvents()
    widget.removeItem(plot)

if __name__ == '__main__':
    pg.exec()
```


Discussion:
I am not sure that your example is valid as the documentation for `processEvents` (https://doc.qt.io/qt-6/qcoreapplication.html#processEvents) says that using it in the manner that you are using it results in `DeferredDelete` events not getting processed.

Also, what is the use-case for repeatedly instantiating `PlotItem`?
> I am not sure that your example is valid as the documentation for processEvents (https://doc.qt.io/qt-6/qcoreapplication.html#processEvents) says that using it in the manner that you are using it results in DeferredDelete events not getting processed.

I added this call of `processEvents` only to "see" something. The whole loop takes some seconds an to see the progress I added `plot.plot(x=[0,i, ...` and `app.processEvents()`.
You can remove both lines and you'll experience the same memory growth.

> Also, what is the use-case for repeatedly instantiating PlotItem?

The loop here is just to highlight the problem. 
I'm not arguing, that there are a lot of use cases where this really becomes a problem.
It's just that memory, which is not needed anymore, should be freed. And when I found this, I couldn't just ignore it ;)
I have modified your example so that it executes with the event loop running.
I have tested it on PySide2 and PyQt5 and the memory growth is obvious with PySide2 but not with PyQt5.
Which binding(s) were you using?

This reminds me of #2348 where the leak only occurred on PySide bindings.
```python
from pyqtgraph.Qt import QtCore
import pyqtgraph as pg

app = pg.mkQApp()
glw = pg.GraphicsLayoutWidget(show=True)

N = 1000
i = 0
def update():
    global i
    glw.clear()
    pi = glw.addPlot()
    pi.plot(x=[0,i, N], y=[0.0, 1.0, 0.0])
    i += 1
    if i == N:
        app.quit()

timer = QtCore.QTimer()
timer.timeout.connect(update)
timer.start(0)

pg.exec()
```
Okay, this is interesting.
I'm using PySide2.
So this could actually be a bug in PySide2 instead of incorrect usage or missing destruction of these `QMenu` instances?
Unlike #2173, there's nothing left in the `items()` of `GraphicsLayoutWidget`.
The comment in `PlotItem::close()` https://github.com/pyqtgraph/pyqtgraph/blob/master/pyqtgraph/graphicsItems/PlotItem/PlotItem.py#L379 says that historically PySide1 has had issues.
Calling that method (which does have code to destroy the QMenu) doesn't seem to stop the leak.
Shows the leaked objects are `QMenu`, `QAction`, `QIcon`.
By setting `enableMenu=False`, the number is reduced but still present. (`ViewBox` menu items won't be created if not enabled but `PlotItem` creates them anyway)

```python
import PySide6
from shiboken6 import getAllValidWrappers
import pyqtgraph as pg

pg.mkQApp()

glw = pg.GraphicsLayoutWidget()
w1 = getAllValidWrappers()
glw.addPlot(enableMenu=True)
glw.clear()
w2 = getAllValidWrappers()
dw = [x for x in w2 if x not in w1]
print(len(dw))

for x in dw:
    print(x)
```
> So this could actually be a bug in PySide2 instead of incorrect usage or missing destruction of these QMenu instances?

I suppose there are differences in object life-cycle management between PyQt and PySide. Whether the difference in behaviour of one binding with respect to the other binding constitutes a "bug" is arguable.

The fix that got merged only addresses PlotItem and ViewBox. It is not known if the other many uses of QMenu within the library also have this issue. 

Thank you for identifying this issue. 

Thank you for tackling this issue so rapidly! :)
Reopening due to having to revert #2502 
A version of the script that runs on both PySide and PyQt.
`QAction`s won't be included as they aren't `QWidget`s. 

```python
import pyqtgraph as pg

app = pg.mkQApp()

glw = pg.GraphicsLayoutWidget()
w1 = app.allWidgets()
glw.addPlot(enableMenu=True)
glw.clear()
w2 = app.allWidgets()
dw = [x for x in w2 if x not in w1]
print(len(dw))

for x in dw:
    print(x)
```
A pyqtgraph-free script that demonstrates the issue. If a `QMenu` is added as a submenu, then the submenu gets leaked. It occurs only on PySide bindings.
```python
from PySide6 import QtWidgets
import shiboken6 as shiboken

app = QtWidgets.QApplication([])
topmenu = QtWidgets.QMenu()
topmenu.setObjectName("topmenu")
submenu = QtWidgets.QMenu()
submenu.setObjectName("submenu")

print(shiboken.dump(submenu))
topmenu.addMenu(submenu)
print(shiboken.dump(submenu))

del topmenu
del submenu
aw = app.allWidgets()
for x in aw:
    print(shiboken.dump(x))
```

Output is
```
C++ address....... PySide6.QtWidgets.QMenu/000002C68229B920
hasOwnership...... 1
containsCppWrapper 1
validCppObject.... 1
wasCreatedByPython 1

C++ address....... PySide6.QtWidgets.QMenu/000002C68229B920
hasOwnership...... 0
containsCppWrapper 1
validCppObject.... 1
wasCreatedByPython 1
parent............ <PySide6.QtWidgets.QMenu(0x2c68229b610, name="topmenu") at 0x000002C6FFA78DC0>

C++ address....... PySide6.QtWidgets.QMenu/000002C68229B920
hasOwnership...... 0
containsCppWrapper 1
validCppObject.... 1
wasCreatedByPython 1
```
Note that C++ `QMenu::addMenu(QMenu*)` does not take ownership of the submenu.
What has happened here is that the PySide binding (as opposed to C++ Qt) has:
1) made topmenu the (Python) parent of submenu.
2) made topmenu (Python wrapper) take over ownership of the submenu C++ object
    * this means that when submenu (Python wrapper) goes out of scope, it doesn't destroy the submenu C++ object

By right, when topmenu (Python wrapper) goes out of scope, it should destroy the submenu C++ object that it has taken ownership of. But it doesn't.

A minimum example in the same spirit as https://bugreports.qt.io/browse/PYSIDE-340
In PYSIDE-340, it's `QToolButton::setMenu(QMenu*)` vs in this case it's `QMenu::addMenu(QMenu*)`.
In both cases, the C++ method is documented to *not* take ownership of the passed in `QMenu`.
The fix in PYSIDE-340 was to make the binding follow the C++ documentation.
```python
from PySide6 import QtWidgets
import sys

app = QtWidgets.QApplication([])
topmenu = QtWidgets.QMenu()
submenu = QtWidgets.QMenu()

print(sys.getrefcount(submenu)) # 2
topmenu.addMenu(submenu)
print(sys.getrefcount(submenu)) # 3
del topmenu
print(sys.getrefcount(submenu)) # 3
```


Resolving patch:
diff --git a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py
--- a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py
+++ b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py
@@ -207,6 +207,7 @@ def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None
             sm.addAction(act)
             self.subMenus.append(sm)
             self.ctrlMenu.addMenu(sm)
+            sm.setParent(None)  # workaround PySide bug (present at least up to 6.4.0)
         
         self.stateGroup = WidgetGroup()
         for name, w in menuItems:
diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py
--- a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py
+++ b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py
@@ -33,6 +33,7 @@ def __init__(self, view):
             a.setDefaultWidget(w)
             m.addAction(a)
             self.addMenu(m)
+            m.setParent(None)  # workaround PySide bug (present at least up to 6.4.0)
             self.axes.append(m)
             self.ctrl.append(ui)
             wg = WidgetGroup(w)


Test patch:
diff --git a/tests/test_qmenu_leak_workaround.py b/tests/test_qmenu_leak_workaround.py
new file mode 100644
--- /dev/null
+++ b/tests/test_qmenu_leak_workaround.py
@@ -0,0 +1,25 @@
+import sys
+import pyqtgraph as pg
+from pyqtgraph.Qt import QtWidgets
+
+def test_qmenu_leak_workaround():
+    # refer to https://github.com/pyqtgraph/pyqtgraph/pull/2518
+    pg.mkQApp()
+    topmenu = QtWidgets.QMenu()
+    submenu = QtWidgets.QMenu()
+
+    refcnt1 = sys.getrefcount(submenu)
+
+    # check that after the workaround,
+    # submenu has no change in refcnt
+    topmenu.addMenu(submenu)
+    submenu.setParent(None) # this is the workaround for PySide{2,6},
+                            # and should have no effect on bindings
+                            # where it is not needed.
+    refcnt2 = sys.getrefcount(submenu)
+    assert refcnt2 == refcnt1
+    
+    # check that topmenu is not a C++ parent of submenu.
+    # i.e. deleting topmenu leaves submenu alive
+    del topmenu
+    assert pg.Qt.isQObjectAlive(submenu)
\ No newline at end of file
diff --git a/tests/test_ref_cycles.py b/tests/test_ref_cycles.py
--- a/tests/test_ref_cycles.py
+++ b/tests/test_ref_cycles.py
@@ -13,10 +13,7 @@
 
 def assert_alldead(refs):
     for ref in refs:
-        obj = ref()
-        assert obj is None or (
-            isinstance(obj, pg.QtCore.QObject) and not pg.Qt.isQObjectAlive(obj)
-        )
+        assert ref() is None
 
 def qObjectTree(root):
     """Return root and its entire tree of qobject children"""