Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why is PySide's exception handling extending this object's lifetime?

tl;dr -- In a PySide application, an object whose method throws an exception will remain alive even when all other references have been deleted. Why? And what, if anything, should one do about this?

In the course of building a simple CRUDish app using a Model-View-Presenter architecture with a PySide GUI, I discovered some curious behavior. In my case:

  • The interface is divided into multiple Views -- i.e., each tab page displaying a different aspect of data might be its own class of View
  • Views are instantiated first, and in their initialization, they instantiate their own Presenter, keeping a normal reference to it
  • A Presenter receives a reference to the View it drives, but stores this as a weak reference (weakref.ref) to avoid circularity
  • No other strong references to a Presenter exist. (Presenters can communicate indirectly with the pypubsub messaging library, but this also stores only weak references to listeners, and is not a factor in the MCVE below.)
  • Thus, in normal operation, when a View is deleted (e.g., when a tab is closed), its Presenter is subsequently deleted as its reference count becomes 0

However, a Presenter of which a method has thrown an exception does not get deleted as expected. The application continues to function, because PySide employs some magic to catch exceptions. The Presenter in question continues to receive and respond to any View events bound to it. But when the View is deleted, the exception-throwing Presenter remains alive until the whole application is closed. An MCVE (link for readability):

import logging
import sys
import weakref

from PySide import QtGui


class InnerPresenter:
    def __init__(self, view):
        self._view = weakref.ref(view)
        self.logger = logging.getLogger('InnerPresenter')
        self.logger.debug('Initializing InnerPresenter (id:%s)' % id(self))

    def __del__(self):
        self.logger.debug('Deleting InnerPresenter (id:%s)' % id(self))

    @property
    def view(self):
        return self._view()

    def on_alert(self):
        self.view.show_alert()

    def on_raise_exception(self):
        raise Exception('From InnerPresenter (id:%s)' % id(self))


class OuterView(QtGui.QMainWindow):
    def __init__(self, *args, **kwargs):
        super(OuterView, self).__init__(*args, **kwargs)
        self.logger = logging.getLogger('OuterView')
        # Menus
        menu_bar = self.menuBar()
        test_menu = menu_bar.addMenu('&Test')
        self.open_action = QtGui.QAction('&Open inner', self, triggered=self.on_open, enabled=True)
        test_menu.addAction(self.open_action)
        self.close_action = QtGui.QAction('&Close inner', self, triggered=self.on_close, enabled=False)
        test_menu.addAction(self.close_action)

    def closeEvent(self, event, *args, **kwargs):
        self.logger.debug('Exiting application')
        event.accept()

    def on_open(self):
        self.setCentralWidget(InnerView(self))
        self.open_action.setEnabled(False)
        self.close_action.setEnabled(True)

    def on_close(self):
        self.setCentralWidget(None)
        self.open_action.setEnabled(True)
        self.close_action.setEnabled(False)


class InnerView(QtGui.QWidget):
    def __init__(self, *args, **kwargs):
        super(InnerView, self).__init__(*args, **kwargs)
        self.logger = logging.getLogger('InnerView')
        self.logger.debug('Initializing InnerView (id:%s)' % id(self))
        self.presenter = InnerPresenter(self)
        # Layout
        layout = QtGui.QHBoxLayout(self)
        alert_button = QtGui.QPushButton('Alert!', self, clicked=self.presenter.on_alert)
        layout.addWidget(alert_button)
        raise_button = QtGui.QPushButton('Raise exception!', self, clicked=self.presenter.on_raise_exception)
        layout.addWidget(raise_button)
        self.setLayout(layout)

    def __del__(self):
        super(InnerView, self).__del__()
        self.logger.debug('Deleting InnerView (id:%s)' % id(self))

    def show_alert(self):
        QtGui.QMessageBox(text='Here is an alert').exec_()


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)
    app = QtGui.QApplication(sys.argv)
    view = OuterView()
    view.show()
    sys.exit(app.exec_())

Open and close the inner view, and you'll see both view and presenter are deleted as expected. Open the inner view, click the button to trigger an exception on the presenter, then close the inner view. The view will be deleted, but the presenter won't until the application exits.

Why? Presumably whatever it is that catches all exceptions on behalf of PySide is storing a reference to the object that threw it. Why would it need to do that?

How should I proceed (aside from writing code that never causes exceptions, of course)? I have enough sense not to rely on __del__ for resource management. I get that I have no right to expect anything subsequent to a caught-but-not-really-handled exception to go ideally but this just strikes me as unnecessarily ugly. How should I approach this in general?

like image 726
grayshirt Avatar asked Sep 28 '14 15:09

grayshirt


1 Answers

The problem is sys.last_tracback and sys.last_value.

When a traceback is raised interactively, and this seems to be what is emulated, the last exception and its traceback are stores in sys.last_value and sys.last_traceback respectively.

Doing

del sys.last_value
del sys.last_traceback

# for consistency, see
# https://docs.python.org/3/library/sys.html#sys.last_type
del sys.last_type

will free the memory.

It's worth noting that at most one exception and traceback pair can get cached. This means that, because you're sane and don't rely on del, there isn't a massive amount of damage to be done.

But if you want to reclaim the memory, just delete those values.

like image 159
Veedrac Avatar answered Nov 02 '22 23:11

Veedrac