title: Why isn't my PyQt application closing correctly
body: <p>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.</p>
  <p>There are two main heavy processes that occur during loading:</p>
  <ul>
  <li>Put image from filesystem --&gt; memory, perform thumbnailing operation (<code>spawnThreads</code> method)</li>
  <li>Add widget to layout (<code>updateThumbs</code> method)</li>
  </ul>
  <p>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 <code>addWidget</code>. Before, this caused my application to freeze, but now I use <code>QCoreApplication.processEvents()</code> and the UI is able to continue working fine.</p>
  <p>However, if I try to close the program (with the &quot;X&quot; 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 <em>after</em> the <code>addWidget</code> operations are done, everything closes fine.</p>
  <p>Here is the relevant code:</p>
  <pre class="lang-py prettyprint-override"><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 = &quot;placeholder.png&quot;
  IMAGES_PATH = &quot;gallery_images/&quot;
  
  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(&quot;RGBA&quot;)
          data = pImg.tobytes(&quot;raw&quot;,&quot;RGBA&quot;)
          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(&quot;Controller initialized&quot;)
          self.app = QApplication([])
          self.window = Gallery(self)
          self.thumbsLoader = ImageManager()
          self.window.tryingToClose.connect(self.thumbsLoader.stopWorking)
          self.paths = []
          print(&quot;Window initialized&quot;)
  
      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) &gt; 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()
  </code></pre>
  <p>If you want to try to run this, try setting the <code>IMAGES_PATH</code> to a directory with atleast 10-15 images, or increase the <code>time.sleep</code> 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.</p>
  <p><strong>Edit: Problem solved!</strong> It was pointed out by @Demi-Lune. I accidentally was running the blocking code before the main event loop:</p>
  <pre class="lang-py prettyprint-override"><code>self.loadPaths(paths) #&lt;--- long running
  sys.exit(self.app.exec()) #&lt;--- start of event loop
  </code></pre>
  <p>Simply changing it so that the long running task starts with a pushbutton fixes the issue.</p>