Search code examples
pythonmultithreadingpyqtpyside

Why isn't my PyQt application closing correctly


I am making an image gallery of sorts where I want to be able to peruse a large sample of images (~1500) at a time. Everything's alright so far but I have run into a bug that happens when I try to close the program.

There are two main heavy processes that occur during loading:

  • Put image from filesystem --> memory, perform thumbnailing operation (spawnThreads method)
  • Add widget to layout (updateThumbs method)

The first task I use multiple threads to perform and that works fine. The second, I have to perform on the main thread since thats the only place you can do addWidget. Before, this caused my application to freeze, but now I use QCoreApplication.processEvents() and the UI is able to continue working fine.

However, if I try to close the program (with the "X" button) while the second task is still working, the UI closes, but Python remains open in the background and the shell remains unresponsive. I have to manually kill Python from Task Manager to regain control of the shell. I have tried to use signals to tell everything to stop working when the main window is trying to close but the effect still remains. To clarify, if I try to close if after the addWidget operations are done, everything closes fine.

Here is the relevant code:

from PySide6.QtCore import QCoreApplication, QObject, QThread, QSize, Signal, Slot
from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QScrollArea, QVBoxLayout, QWidget
from PySide6.QtGui import QImage, QPixmap
from PIL import Image

import sys, os, time

PLACEHOLDER_PATH = "placeholder.png"
IMAGES_PATH = "gallery_images/"

class Gallery(QWidget):
    tryingToClose = Signal()
    def __init__(self, controller):
        self.controller = controller
        super().__init__()

        self.stopWorking = False
        self.minThumbWidth = 128
        self.thumbs = []
        self.thumbPlaceholderPath = PLACEHOLDER_PATH
        self.thumbPlaceholderPixmap = QPixmap(self.thumbPlaceholderPath)
        self.setupUI()

    def closeEvent(self, event):
        event.accept()
        self.tryingToClose.emit() # let the window close
        self.stopWorking = True

    def clearThumbs(self):
        self.thumbs = []
        while self.thumbsLayout.count():
            child = self.layout.takeAt(0)
            if child.widget():
                child.widget().deleteLater()

    def generateThumbs(self, paths):
        self.clearThumbs()
        self.thumbs = [ImageThumb(path, self.minThumbWidth, self.thumbPlaceholderPixmap) for path in paths]

    def updateThumbs(self):
        print('Starting op2 (closing shouldn\'t work properly now)')
        for i, thumb in enumerate(self.thumbs): #this is heavy operation #2
            if self.stopWorking: 
                print('Aborting thumbs loading (closing will still be broken, but why?)')
                return

            self.thumbsLayout.addWidget(thumb)
            #i add this sleep here to simulate having lots of images to process, in case you dont have a directory with a lot of images
            # without this you have to click very fast to close the window to observe the effect
            time.sleep(0.1)
            QCoreApplication.processEvents()
        print('Finished op2 (closing should work fine now)')

    def setupUI(self):
        self.mainFrame = QVBoxLayout()
        self.scrollArea = QScrollArea()
        self.scrollFrame = QWidget()

        self.thumbsLayout = QHBoxLayout(self.scrollFrame)
        self.scrollArea.setWidgetResizable(True)
        self.scrollArea.setWidget(self.scrollFrame)
        self.scrollFrame.setLayout(self.thumbsLayout)
        self.mainFrame.addWidget(self.scrollArea)
        
        self.setLayout(self.mainFrame)

class ImageThumb(QLabel):
    def __init__(self, path, size, placeholder):
        super().__init__()
        self.thumbSize = size
        self.placeholder = placeholder.scaled(QSize(size, size))
        self.path = path
        self.setMinimumSize(1, 1)
        self.setPixmap(self.placeholder)

    def setup(self):
        pImg = Image.open(self.path)
        pImg.thumbnail((self.thumbSize, self.thumbSize))
        pImg = pImg.convert("RGBA")
        data = pImg.tobytes("raw","RGBA")
        qImg = QImage(data, pImg.size[0], pImg.size[1], QImage.Format.Format_RGBA8888)
        qImg = QPixmap.fromImage(qImg)
        self.setPixmap(qImg)


class ImageWorker(QObject):
    tookThumb = Signal(int, int)
    finishedThumb = Signal(int, int)
    finished = Signal(int)

    def __init__(self, workerIndex, manager):
        super().__init__()
        self.index = workerIndex
        self.manager = manager

    @Slot()
    def run(self):
        try:
            while self.manager.doesWorkExist():
                thumbIndex, thumb = self.manager.takeThumb(self.index)
                if thumbIndex == -1:
                    self.finished.emit(self.index)
                    return

                self.tookThumb.emit(self.index, thumbIndex)
                thumb.setup()
                self.finishedThumb.emit(self.index, thumbIndex)
        except Exception as e:
            print(f'Worker {self.index} died to {e}')
        self.finished.emit(self.index)

class ImageManager(QObject):
    def __init__(self, ):
        super().__init__()
        self.thumbWidgets = []#thumbList
        self.maxThreads = 4
        self.thumbLocks = {}
        self.threads = [QThread() for i in range(self.maxThreads)]
        self.workers = [ImageWorker(i, self) for i in range(self.maxThreads)]
        self.ignoreWork = False

    def setThumbsList(self, newList):
        self.thumbWidgets = newList
        self.thumbLocks = {i: False for i in range(len(self.thumbWidgets))}

    def doesWorkExist(self):
        allThumbsLocked = all(self.thumbLocks.values())
        return (not allThumbsLocked) and (not self.ignoreWork)

    def stopWorking(self):
        self.ignoreWork = True
        for thread in self.threads:
            thread.quit()

    def takeThumb(self, workerIndex):
        for thumbIndex, isLocked in self.thumbLocks.items():
            if isLocked == False:
                self.thumbLocks[thumbIndex] = True
                return thumbIndex, self.thumbWidgets[thumbIndex]
        
        return -1, None

    def spawnThreads(self): #heavy operation #1 but on different threads
        for index, thread in enumerate(self.threads):
            worker = self.workers[index]
            worker.moveToThread(thread)
            thread.started.connect(worker.run)
            worker.finished.connect(thread.quit)
            thread.start()
    

class GalleryController(object):
    def __init__(self):
        print("Controller initialized")
        self.app = QApplication([])
        self.window = Gallery(self)
        self.thumbsLoader = ImageManager()
        self.window.tryingToClose.connect(self.thumbsLoader.stopWorking)
        self.paths = []
        print("Window initialized")

    def loadPaths(self, newPaths):
        self.paths = self.filterPaths(newPaths)
        self.window.generateThumbs(self.paths)
        self.thumbsLoader.setThumbsList(self.window.thumbs)
        self.thumbsLoader.spawnThreads()
        self.window.updateThumbs()

    def isPicture(self, path):
        pictureExts = ['png', 'jpg', 'jpeg']
        entryName = os.path.basename(path)
        ext = ('.' in entryName) and entryName[entryName.index('.') + 1:]
        return ext and len(ext) > 0 and (ext in pictureExts)
    
    def filterPaths(self, pathList):
        return filter(self.isPicture, pathList)

    def start(self):
        self.window.show()
        mainPath = IMAGES_PATH
        paths = os.listdir(mainPath)
        paths = [os.path.join(mainPath, path) for path in paths]
        self.loadPaths(paths)
        sys.exit(self.app.exec())

control = GalleryController()
control.start()

If you want to try to run this, try setting the IMAGES_PATH to a directory with atleast 10-15 images, or increase the time.sleep so you can observe the effect. I removed some unnecessary code so the images will clip on each other if you load many but just to clarify that's not the problem.

Edit: Problem solved! It was pointed out by @Demi-Lune. I accidentally was running the blocking code before the main event loop:

self.loadPaths(paths) #<--- long running
sys.exit(self.app.exec()) #<--- start of event loop

Simply changing it so that the long running task starts with a pushbutton fixes the issue.


Solution

  • The problem was I was executing the long-running code before the start of the main event loop.

    self.loadPaths(paths) #<--- long running
    sys.exit(self.app.exec()) #<--- start of main event loop
    

    @Demi-Lune points out that one way to solve this is to start a QThread before the event loop that hosts the blocking code, but my use case is solved by connecting the blocking code to the click of a QPushButton.