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)
```

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
@@ -201,7 +201,7 @@ def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None
         self.ctrlMenu.setTitle(translate("PlotItem", 'Plot Options'))
         self.subMenus = []
         for name, grp in menuItems:
-            sm = QtWidgets.QMenu(name)
+            sm = QtWidgets.QMenu(name, self.ctrlMenu)
             act = QtWidgets.QWidgetAction(self)
             act.setDefaultWidget(grp)
             sm.addAction(act)
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
@@ -24,7 +24,7 @@ def __init__(self, view):
         self.widgetGroups = []
         self.dv = QtGui.QDoubleValidator(self)
         for axis in 'XY':
-            m = QtWidgets.QMenu()
+            m = QtWidgets.QMenu(self)
             m.setTitle(f"{axis} {translate('ViewBox', 'axis')}")
             w = QtWidgets.QWidget()
             ui = ui_template.Ui_Form()
@@ -55,12 +55,8 @@ def __init__(self, view):
 
         self.ctrl[0].invertCheck.toggled.connect(self.xInvertToggled)
         self.ctrl[1].invertCheck.toggled.connect(self.yInvertToggled)
-        ## exporting is handled by GraphicsScene now
-        #self.export = QtWidgets.QMenu("Export")
-        #self.setExportMethods(view.exportMethods)
-        #self.addMenu(self.export)
         
-        self.leftMenu = QtWidgets.QMenu(translate("ViewBox", "Mouse Mode"))
+        self.leftMenu = QtWidgets.QMenu(translate("ViewBox", "Mouse Mode"), self)
         group = QtGui.QActionGroup(self)
         
         # This does not work! QAction _must_ be initialized with a permanent 
@@ -85,13 +81,6 @@ def __init__(self, view):
         
         self.updateState()
 
-    def setExportMethods(self, methods):
-        self.exportMethods = methods
-        self.export.clear()
-        for opt, fn in methods.items():
-            self.export.addAction(opt, self.exportMethod)
-        
-
     def viewStateChanged(self):
         self.valid = False
         if self.ctrl[0].minText.isVisible() or self.ctrl[1].minText.isVisible():
@@ -210,10 +199,6 @@ def yInvertToggled(self, b):
     def xInvertToggled(self, b):
         self.view().invertX(b)
 
-    def exportMethod(self):
-        act = self.sender()
-        self.exportMethods[str(act.text())]()
-
     def set3ButtonMode(self):
         self.view().setLeftButtonAction('pan')
         


Test patch:
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,7 +13,10 @@
 
 def assert_alldead(refs):
     for ref in refs:
-        assert ref() is None
+        obj = ref()
+        assert obj is None or not (
+            isinstance(obj, pg.QtCore.QObject) and pg.Qt.isQObjectAlive(obj)
+        )
 
 def qObjectTree(root):
     """Return root and its entire tree of qobject children"""