pyqtgraph/pyqtgraph

Potential Race Conditions Leading to the Unexpected Initialization of a QListWidgetItem Instead of the Widget I Want

MorgenSullivan opened this issue · 10 comments

Short description

Hello! I've believe I've run into multiple race conditions, that I'm having a hard time managing in my application. The latest example occurs when QMenu.addMenu() returns a QListWidgetItem when called from ViewBoxMenu.__init__() or PlotItem.__init__(). Since 2021 I've monkey patched the following each time a new race condition pops up, and the error is always AttributeError: 'PySide2.QtWidgets.QListWidgetItem' object has no attribute '[insert attribute here]:

pg.exporters.Exporter.getPaintItems
pg.graphicsItems.GraphicsItem.GraphicsItem.getViewBox
pg.graphicsItems.GraphicsItem.GraphicsItem._replaceView
pg.graphicsItems.LegendItem.LegendItem.updateSize
pg.GraphicsScene.itemsNearEvent
pg.GraphicsScene.mousePressEvent

As my application grows, the number of these race conditions increases, and they're starting to be difficult to manage.

Supporting Code

It's very difficult for me to reproduce as it is so random, and often ends in a segmentation fault, but I can share the monkey-patches I've implented

def getPaintItemsforExporter(self, root=None):
    """Return a list of all items that should be painted in the correct order."""
    if root is None:
        root = self.item
    preItems = []
    postItems = []
    if isinstance(root, QtWidgets.QGraphicsScene):
        childs = [i for i in root.items() if i.parentItem() is None]
        rootItem = []
    else:
        childs = root.childItems()
        rootItem = [root]
    # # ===================================
    # # PREVIOUS CODE
    # childs.sort(key=lambda a: a.zValue())
    # while len(childs) > 0:
    #     ch = childs.pop(0)
    #     tree = self.getPaintItems(ch)
    #     if (ch.flags() & ch.ItemStacksBehindParent) or \
    #        (ch.zValue() < 0 and (ch.flags() & ch.ItemNegativeZStacksBehindParent)):
    #         preItems.extend(tree)
    #     else:
    #         postItems.extend(tree)

    # =====================================
    # PATCHED BY M. SULLIVAN ON 08-02-2021
    if all(hasattr(item, "zValue") for item in childs):
        childs.sort(key=lambda a: a.zValue())
        while len(childs) > 0:
            ch = childs.pop(0)
            tree = self.getPaintItems(ch)
            if (ch.flags() & ch.ItemStacksBehindParent) or (
                ch.zValue() < 0 and (ch.flags() & ch.ItemNegativeZStacksBehindParent)
            ):
                preItems.extend(tree)
            else:
                postItems.extend(tree)
    # =====================================

    return preItems + rootItem + postItems
def replaceViewGraphicsItem(self, oldView, item=None):
    """monkeypatch of replaceViewGraphicsItem"""
    if item is None:
        item = self
    # # ===================================
    # # PREVIOUS CODE
    # for child in item.childItems():
    #     if isinstance(child, GraphicsItem):
    #         if child.getViewBox() is oldView:
    #             child._updateView()
    #                 #self._replaceView(oldView, child)
    #     else:
    #         self._replaceView(oldView, child)
    # =====================================
    # PATCHED BY M. SULLIVAN ON 08-02-2021
    if hasattr(item, "childItems"):
        for child in item.childItems():
            if isinstance(child, GraphicsItem):
                if child.getViewBox() is oldView:
                    child._updateView()
                    # self._replaceView(oldView, child)
            else:
                self._replaceView(oldView, child)
    # =====================================
# GraphicsItem.getViewBox
def getViewBox(self):
    """
    Return the first ViewBox or GraphicsView which bounds this item's visible space.
    If this item is not contained within a ViewBox, then the GraphicsView is returned.
    If the item is contained inside nested ViewBoxes, then the inner-most ViewBox is
    returned. The result is cached; clear the cache with forgetViewBox()
    """
    if self._viewBox is None:
        p = self
        while True:
            try:
                p = p.parentItem()
            except RuntimeError:
                # sometimes happens as items are being removed from a scene
                # and collected.
                return None
            if p is None:
                vb = self.getViewWidget()
                if vb is None:
                    return None
                else:
                    # =====================================
                    # PREVIOUS CODE
                    #                 self._viewBox = weakref.ref(vb)
                    #                 break
                    # =====================================
                    # PATCHED BY J. RUSSELL ON 09-07-2023
                    if hasattr(vb, "implements") and vb.implements("ViewBox"):
                        self._viewBox = weakref.ref(vb)
                        break

                    try:
                        vb = vb.getViewBox()
                    except AttributeError:
                        return None
                    if hasattr(vb, "implements") and vb.implements("ViewBox"):
                        self._viewBox = weakref.ref(vb)
                        break
                    return None
            # =====================================
            if hasattr(p, "implements") and p.implements("ViewBox"):
                self._viewBox = weakref.ref(p)
                break
    return self._viewBox()  # If we made it this far, _viewBox is definitely not None
def updateSizeLegendItems(self):
    """monkeypatch"""
    if self.size is not None:
        return
    height = 0
    width = 0
    for row in range(self.layout.rowCount()):
        row_height = 0
        col_width = 0
        for col in range(self.layout.columnCount()):
            item = self.layout.itemAt(row, col)
            # # ===================================
            # # PREVIOUS CODE
            # if item:
            #     col_width += item.width() + 3
            #     row_height = max(row_height, item.height())
            # =====================================
            # PATCHED BY M. SULLIVAN ON 08-02-2021
            if item:
                if hasattr(item, "width") and hasattr(item, "height"):
                    col_width += item.width() + 3
                    row_height = max(row_height, item.height())
            # =====================================
        width = max(width, col_width)
        height += row_height
    self.setGeometry(0, 0, width, height)
    return
def mousePressEvent(self, ev):
    """monkeypatch"""
    # # ===================================
    # # PREVIOUS CODE
    # super().mousePressEvent(ev)
    # =====================================
    # PATCHED BY M. SULLIVAN ON 01-24-2022
    super(GraphicsScene, self).mousePressEvent(ev)
    # =====================================
    if (
        self.mouseGrabberItem() is None
    ):  # nobody claimed press; we are free to generate drag/click events
        if self.lastHoverEvent is not None:
            # If the mouse has moved since the last hover event, send a new one.
            # This can happen if a context menu is open while the mouse is moving.
            if ev.scenePos() != self.lastHoverEvent.scenePos():
                self.sendHoverEvents(ev)

        self.clickEvents.append(MouseClickEvent(ev))

        # set focus on the topmost focusable item under this click
        items = self.items(ev.scenePos())
        for i in items:
            # # ===================================
            # # PREVIOUS CODE
            # if i.isEnabled() and i.isVisible() and (i.flags() & i.ItemIsFocusable):
            #     i.setFocus(QtCore.Qt.MouseFocusReason)
            #     break
            # =====================================
            # PATCHED BY M. SULLIVAN ON 01-24-2022
            if (
                hasattr(i, "isEnabled")
                and hasattr(i, "isVisible")
                and hasattr(i, "flags")
                and hasattr(i, "ItemIsFocusable")
            ):
                if i.isEnabled() and i.isVisible() and (i.flags() & i.ItemIsFocusable):
                    i.setFocus(QtCore.Qt.MouseFocusReason)
                    break
            # =====================================
# \venv\Lib\site-packages\pyqtgraph\GraphicsScene\GraphicsScene.py
def itemsNearEventGraphicsScene(
    self,
    event,
    selMode=QtCore.Qt.IntersectsItemShape,
    sortOrder=QtCore.Qt.DescendingOrder,
    hoverable=False,
):
    """Return an iterator that iterates first through the items that directly intersect
    point (in Z order) followed by any other items that are within the scene's click
    radius.
    """
    # # ===================================
    # # PREVIOUS CODE
    # # tr = self.getViewWidget(event.widget()).transform()
    # view = self.views()[0]
    # tr = view.viewportTransform()
    # r = self._clickRadius
    # rect = view.mapToScene(QtCore.QRect(0, 0, 2 * r, 2 * r)).boundingRect()
    #
    # seen = set()
    # if hasattr(event, 'buttonDownScenePos'):
    #     point = event.buttonDownScenePos()
    # else:
    #     point = event.scenePos()
    # w = rect.width()
    # h = rect.height()
    # rgn = QtCore.QRectF(point.x() - w, point.y() - h, 2 * w, 2 * h)
    # # self.searchRect.setRect(rgn)
    #
    # items = self.items(point, selMode, sortOrder, tr)
    #
    # # remove items whose shape does not contain point
    # # (scene.items() apparently sucks at this)
    # items2 = []
    # for item in items:
    #     if hoverable and not hasattr(item, 'hoverEvent'):
    #         continue
    #     if item.scene() is not self:
    #         continue
    #     shape = item.shape()  # Note: default shape() returns boundingRect()
    #     if shape is None:
    #         continue
    #     if shape.contains(item.mapFromScene(point)):
    #         items2.append(item)
    #
    # ## Sort by descending Z-order (don't trust scene.itms() to do this either)
    # ## use 'absolute' z value, which is the sum of all item/parent ZValues
    # def absZValue(item):
    #     if item is None:
    #         return 0
    #     return item.zValue() + absZValue(item.parentItem())
    #
    # items2.sort(key=absZValue, reverse=True)
    #
    # return items2
    #
    # # for item in items:
    # ##seen.add(item)
    #
    # # shape = item.mapToScene(item.shape())
    # # if not shape.contains(point):
    # # continue
    # # yield item
    # # for item in self.items(rgn, selMode, sortOrder, tr):
    # ##if item not in seen:
    # # yield item
    # =====================================
    # PATCHED BY M. SULLIVAN ON 08-29-2022
    # tr = self.getViewWidget(event.widget()).transform()
    view = self.views()[0]
    tr = view.viewportTransform()

    if hasattr(event, "buttonDownScenePos"):
        point = event.buttonDownScenePos()
    else:
        point = event.scenePos()
    # self.searchRect.setRect(rgn)

    items = self.items(point, selMode, sortOrder, tr)

    # remove items whose shape does not contain point
    # (scene.items() apparently sucks at this)
    items2 = []
    for item in items:
        # if the item is missing the scene attribute it is likely a poorly initialized
        # QListWidgetItem so move on
        if hasattr(item, "scene"):
            if hoverable and not hasattr(item, "hoverEvent"):
                continue
            if item.scene() is not self:
                continue
            shape = item.shape()  # Note: default shape() returns boundingRect()
            if shape is None:
                continue
            if shape.contains(item.mapFromScene(point)):
                items2.append(item)
        else:
            continue

    # Sort by descending Z-order (don't trust scene.itms() to do this either)
    # use 'absolute' z value, which is the sum of all item/parent ZValues
    def absZValue(item):
        if item is None:
            return 0
        return item.zValue() + absZValue(item.parentItem())

    items2.sort(key=absZValue, reverse=True)

    return items2

    # for item in items:
    # seen.add(item)

    # shape = item.mapToScene(item.shape())
    # if not shape.contains(point):
    # continue
    # yield item
    # for item in self.items(rgn, selMode, sortOrder, tr):
    # # if item not in seen:
    # yield item
    # =====================================

In the latest issue I'm having, I can see that randomly self.addMenu will return a QListWidgetItem leading to the eventual AttributeError when trying to .addAction(a) to the expected QMenu.

class ViewBoxMenu(QtWidgets.QMenu):
    def __init__(self, view):
        QtWidgets.QMenu.__init__(self)
        
        self.view = weakref.ref(view)  ## keep weakref to view to avoid circular reference (don't know why, but this prevents the ViewBox from being collected)
        self.valid = False  ## tells us whether the ui needs to be updated
        self.viewMap = weakref.WeakValueDictionary()  ## weakrefs to all views listed in the link combos

        self.setTitle(translate("ViewBox", "ViewBox options"))
        self.viewAll = QtGui.QAction(translate("ViewBox", "View All"), self)
        self.viewAll.triggered.connect(self.autoRange)
        self.addAction(self.viewAll)

        self.ctrl = []
        self.widgetGroups = []
        self.dv = QtGui.QDoubleValidator(self)
        for axis in 'XY':
            m = self.addMenu(f"{axis} {translate('ViewBox', 'axis')}")
            w = QtWidgets.QWidget()
            ui = ui_template.Ui_Form()
            ui.setupUi(w)
            a = QtWidgets.QWidgetAction(self)
            a.setDefaultWidget(w)
            m.addAction(a)
            self.ctrl.append(ui)
            wg = WidgetGroup(w)
            self.widgetGroups.append(wg)

If that doesn't create the issue then the exact same behavior will eventually arise in PlotItem.__init__() in the following code:

        for name, grp in menuItems:
            sm = self.ctrlMenu.addMenu(name)
            act = QtWidgets.QWidgetAction(self)
            act.setDefaultWidget(grp)
            sm.addAction(act)

Expected behavior

The expected behavior is that the widgets in the code above will have the correct attribute.

Real behavior

Instead they are initialized as QListWidgetItems and are therefore missing the necessary attributes. The behavior is generally random, although having more data loaded into my application causes them to appear more frequently. Adding print statements to pyqtgraph's code reduces the frequency of them popping up.

Here is only one example, but I have had dozens of different tracebacks, all related to completely different attributes, but all related to the unexpected initialization of a QListWidgetItem.

Traceback (most recent call last):
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\dialogs\main_window.py", line 1786, in add_plot_tab
    self.pyqt_vbr = self.pyqt_pi.create_right_viewbox()
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\common\widgets\custom_plotting.py", line 731, in create_right_viewbox
    self.vbr = HobanViewBox()
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\common\widgets\custom_plotting.py", line 58, in __init__
    super().__init__(*args, **kwargs)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\venv\lib\site-packages\pyqtgraph\graphicsItems\ViewBox\ViewBox.py", line 224, in __init__
    self.menu = ViewBoxMenu(self)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\venv\lib\site-packages\pyqtgraph\graphicsItems\ViewBox\ViewBoxMenu.py", line 70, in __init__
    leftMenu.addActions(group.actions())
AttributeError: 'PySide2.QtWidgets.QListWidgetItem' object has no attribute 'addActions'

Tested environment(s)

  • PyQtGraph version: 0.13.3
  • Qt Python binding: PySide2 5.15.2.1
  • Python version: 3.8
  • NumPy version: 1.20.3
  • Operating system: Windows 10 Pro
  • Installation method: pip

Additional context

I know this is probably a long shot to figure this out, but I was hoping you might have an idea why I'm running into these race conditions all over my application.

Thank you in advance for your help on this!!

You are suspecting that it is due to a "race condition" because you are using multiple threads?

Hi @pijyoi thank you for the quick response! I am actually not using multiple threads - I'm only using signals and slots to control all my processes. The reason I suspect it's a race condition is the following:

  • The issues are random
  • I can only reproduce the errors by repeating the same operation many times
  • They appear at different frequencies on different computers (some of my users get them all the time, while others don't)
  • Adding print statements around the offending code appears to impact the frequency and location of the errors
  • The most nefarious of instances result in a segmentation fault which immediately crashes my application

I've really struggled to understand what causes this, but here is some potentially useful information:

  • Having more data loaded into memory increases the likelihood of this appearing
  • I think the issue usually arises when initializing the objects from within my promoted instances of a PlotWidget or a TableWidget
  • The incorrectly initialized object is (almost?) always a QListWidgetItem

Hi @j9ac9k, thanks for the suggestion! I am using those throughout my application to update the GUI when long for loops are running in the back end. Unfortunately, I just removed all calls to .processEvents() and the issue just appeared again:

Traceback (most recent call last):
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\dialogs\main_window.py", line 1753, in add_plot_tab
    plot_widget = HobanPlotWidget(new_tab)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\common\widgets\promoted.py", line 68, in __init__
    self.plotItem = DualAxisPlotItem(**kargs)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\common\widgets\custom_plotting.py", line 707, in __init__
    super().__init__(
  File "C:\Users\morgen.sullivan\Python\04_Hoban\venv\lib\site-packages\pyqtgraph\graphicsItems\PlotItem\PlotItem.py", line 205, in __init__
    sm.addAction(act)
AttributeError: 'PySide2.QtWidgets.QListWidgetItem' object has no attribute 'addAction'

I should also mention that I did both remove and add calls to .processEvents() when I first discovered these issues, and it didn't seem to have any effect on the frequency of them, although this was 3 years ago, so potentially I was doing something silly back then.

Thanks for the heads up on these, though! I'll try to avoid using them going forward.

Update

I've implemented a workaround in my code that has completely stopped the behavior from appearing in the latest instance of the bug. This is a brute-force way of handling this, and it explicitly slows down my application since I'm calling time.sleep(), but here is what I've done:

Band-Aid Function

def execute_with_retries(method, *arguments, iterations=100):
    """
    Tries to execute the given method with the provided arguments, retrying on failure.

    Parameters:
    - method: The method to execute.
    - *arguments: Arguments to pass to the method.
    - iterations: Number of times to retry (default is 100).

    Returns:
    - The result of the method if successful.
    - None if all attempts fail.
    """
    for _ in range(iterations):
        try:
            return method(*arguments)
        except AttributeError:
            time.sleep(0.02)
            continue
    else:
        # statistically I should never get here
        return None  # Return None if all attempts fail

Usage

plot_widget = execute_with_retries(PlotWidget, self.plot_tab_widget.currentWidget())
if plot_widget is None:
    # this means the bug has beat the odds
    print("An internal error has occurred. It is recommended to restart the application.")

It seems that over the course of your program execution, you will create new instances of PlotWidget?

What happens to previous instances of PlotWidget?

@pijyoi that is correct. Each time I create a new PlotWidget, I add it to a list which is stored as an attribute of my MainWindow:

self.plot_widgets.append(plot_widget)

Presumably the code that instantiates the new PlotWidget is in some slot that gets triggered by some signal?

You could try changing the connection to that slot to a QueuedConnection.

As pijyoi mentioned, a "QueuedConnection" is likely a far better solution than introducing a sleep call, and depending on the signal you're using it might actually be what you want.

Also I would recommend against PySide2 bindings, it's been quite some time since they've been updated, they won't receive any updates in the future, and it won't be long before PyQtGraph stops supporting PySide2 with how we're deprecating support for Python versions per NEP-29.

That said, I do want to leave the issue open and take a closer look at the patches you've made (thanks for sharing btw!) and see if they may present a more robust solution. Some of the code being patched here goes back ages...

Thank you for the suggestion on the QueuedConnection. Changing to a QueuedConnection does appear to have changed when the error occurs, and it seemed like it was harder to reproduce it, but it still appeared:

Traceback (most recent call last):
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\dialogs\main_window.py", line 1725, in add_plot_tab
    plot_widget = HobanPlotWidget(new_tab)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\common\widgets\promoted.py", line 68, in __init__
    self.plotItem = DualAxisPlotItem(**kargs)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\hoban\common\widgets\custom_plotting.py", line 707, in __init__
    super().__init__(
  File "C:\Users\morgen.sullivan\Python\04_Hoban\venv\lib\site-packages\pyqtgraph\graphicsItems\PlotItem\PlotItem.py", line 209, in __init__
    self.stateGroup.autoAdd(w)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\venv\lib\site-packages\pyqtgraph\WidgetGroup.py", line 205, in autoAdd
    self.autoAdd(c)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\venv\lib\site-packages\pyqtgraph\WidgetGroup.py", line 205, in autoAdd
    self.autoAdd(c)
  File "C:\Users\morgen.sullivan\Python\04_Hoban\venv\lib\site-packages\pyqtgraph\WidgetGroup.py", line 204, in autoAdd
    for c in obj.children():
AttributeError: 'PySide2.QtWidgets.QListWidgetItem' object has no attribute 'children'

Thanks for the heads up on PySide2. I've been dreading upgrading, because our application contains about 75,000 lines of code, and PySide2 has become deeply integrated in our source code.