2

First I'll give a short description of the user interface and its functions before diving into the problem and code. Sorry in advance but I am unable to provide the complete code (even if I could it's...a lot of lines :D).

Description of the UI and what it does I have a custom QWidget or to be more precise N instances of that custom widget aligned in a grid layout. Each instance of the widget has its own QThread which holds a worker QObject and a QTimer. In terms of UI components the widget contains two important components - a QLabel which visualizes a status and a QPushButton, which either starts (by triggering a start() slot in the Worker) or stops (by triggering a slot() slot in the worker) an external process. Both slots contain a 5s delay and also disable the push button during their execution. The worker itself not only controls the external process (through calls to the two slots mentioned above) but also checks if the process is running by a status() slot, which is triggered by the QTimer every 1s. As mentioned both the worker and timer live inside the thread! (I have double-checked that by printing the thread ID of the main (where the UI is) and the one of each worker (different from the main 100% sure).

In order to reduce the amount of calls from the UI to the worker and vice versa I decided to declare the _status attribute (which hold the state of the external process - inactive, running, error) of my Worker class as Q_PROPERTY with a setter, getter and notify the last being a signal triggered from within the setter IF and only if the value has changed from the old one. My previous design was much more signal/slot intensive since the status was emitted literally every second.

Now it's time for some code. I have reduced the code only to the parts which I deem to provide enough info and the location where the problem occurs:


Inside the QWidget

# ...

def createWorker(self):
    # Create thread
    self.worker_thread = QThread()

    # Create worker and connect the UI to it
    self.worker = None
    if self.pkg: self.worker = Worker(self.cmd, self.pkg, self.args)
    else: self.worker = Worker(cmd=self.cmd, pkg=None, args=self.args)

    # Trigger attempt to recover previous state of external process
    QTimer.singleShot(1, self.worker.recover)

    self.worker.statusChanged_signal.connect(self.statusChangedReceived)
    self.worker.block_signal.connect(self.block)
    self.worker.recover_signal.connect(self.recover)
    self.start_signal.connect(self.worker.start)
    self.stop_signal.connect(self.worker.stop)
    self.clear_error_signal.connect(self.worker.clear_error)

    # Create a timer which will trigger the status slot of the worker every 1s (the status slot sends back status updates to the UI (see statusChangedReceived(self, status) slot))
    self.timer = QTimer()
    self.timer.setInterval(1000)
    self.timer.timeout.connect(self.worker.status)

    # Connect the thread to the worker and timer
    self.worker_thread.finished.connect(self.worker.deleteLater)
    self.worker_thread.finished.connect(self.timer.deleteLater)
    self.worker_thread.started.connect(self.timer.start)

    # Move the worker and timer to the thread...
    self.worker.moveToThread(self.worker_thread)
    self.timer.moveToThread(self.worker_thread)

    # Start the thread
    self.worker_thread.start()

@pyqtSlot(int)
  def statusChangedReceived(self, status):
    '''
    Update the UI based on the status of the running process
    :param status - status of the process started and monitored by the worker
    Following values for status are possible:
      - INACTIVE/FINISHED - visual indicator is set to INACTIVE icon; this state indicates that the process has stopped running (without error) or has never been started
      - RUNNING - if process is started successfully visual indicator
      - FAILED_START - occurrs if the attempt to start the process has failed
      - FAILED_STOP - occurrs if the process wasn't stop from the UI but externally (normal exit or crash)
    '''
    #print(' --- main thread ID: %d ---' % QThread.currentThreadId())
    if status == ProcStatus.INACTIVE or status == ProcStatus.FINISHED:
      # ...
    elif status == ProcStatus.RUNNING:
      # ...
    elif status == ProcStatus.FAILED_START:
      # ...
    elif status == ProcStatus.FAILED_STOP:
      # ...

@pyqtSlot(bool)
  def block(self, block_flag):
    '''
    Enable/Disable the button which starts/stops the external process
    This slot is used for preventing the user to interact with the UI while starting/stopping the external process after a start/stop procedure has been initiated
    After the respective procedure has been completed the button will be enabled again
    :param block_flag - enable/disable flag for the button
    '''
    self.execute_button.setDisabled(block_flag)


# ...

Inside the Worker

# ...

  @pyqtSlot()
  def start(self):
    self.block_signal.emit(True)
    if not self.active and not self.pid:
      self.active, self.pid = QProcess.startDetached(self.cmd, self.args, self.dir_name)
      QThread.sleep(5)

      # Check if launching the external process was successful
      if not self.active or not self.pid:
        self.setStatus(ProcStatus.FAILED_START)
        self.block_signal(False)
        self.cleanup()
        return

      self.writePidToFile()
      self.setStatus(ProcStatus.RUNNING)

    self.block_signal.emit(False)

  @pyqtSlot()
  def stop(self):
    self.block_signal.emit(True)
    if self.active and self.pid:
        try:
          kill(self.pid, SIGINT)
          QThread.sleep(5) # <----------------------- UI freezes here
        except OSError:
          self.setStatus(ProcStatus.FAILED_STOP)

    self.cleanup()
    self.active = False
    self.pid = None
    self.setStatus(ProcStatus.FINISHED)
    self.block_signal.emit(False)

  @pyqtSlot()
  def status(self):
    if self.active and self.pid:
      running = self.checkProcessRunning(self.pid)
      if not running:
        self.setStatus(ProcStatus.FAILED_STOP)
        self.cleanup()
        self.active = False
        self.pid = None

  def setStatus(self, status):
    if self._status == status: return
    #print(' --- main thread ID: %d ---' % QThread.currentThreadId())
    self._status = status
    self.statusChanged_signal.emit(self._status)

And now about my problem: I have noticed that the UI freezes ONLY whenever the stop() slot is triggered and the execution of the code goes through the QThread.sleep(5). I thought that this should also affect start but with multiple instances of my widget (each controlling its own thread with a worker and timer living in it) and all of that running the start works as intended - the push button, which is used to trigger the start() and stop() slots, gets disabled for 5 seconds and then gets enabled. With the stop() being triggered this doesn't happen at all.

I really cannot explain this behaviour. What's even worse is that the status updates that I am emitting through the Q_PROPERTY setter self.setStatus(...) get delayed due to this freezing which leads to some extra calls of my cleanup() function which basically deletes a generated file.

Any idea what is going on here? The nature of a slot and signal is that once a signal is emitted the slot connected to it is called right away. And since the UI runs in a different thread then the worker I don't see why all this is happening.

rbaleksandar
  • 8,713
  • 7
  • 76
  • 161

1 Answers1

0

I actually corrected the spot where the problem was coming from in my question. In my original code I have forgotten the @ before the pyqtSlot() of my stop() function. After adding it, it works perfectly fine. I had NO IDEA that such a thing can cause such huge problem!

rbaleksandar
  • 8,713
  • 7
  • 76
  • 161
  • The linked topic doesn't give a hint what happens if you put `pyqtSlot()` as a function call in your code and not as a decorator. I know what `@pyqtSlot()` is used for though I had no idea that I can actually write it as a function call (without the `@`) and not get any errors whatsoever. This typo was the last place I looked while analyzing the possible source(s) of my problem. :-/ As mentioned in my answer after placing that `@` everything works like a charm. :) – rbaleksandar Apr 02 '16 at 11:03
  • 1
    Writing just `pyqtSlot()` is the same as not using the decorator at all (which is what the linked Q/A describes) and explains why your code didn't work without it (you don't usually need to decorate with `@pyqtSlot()`, but when working with threads it is necessary depending on the order of connection and moving the `QObject` to a thread). The reason you saw no errors is because you are allow to have any valid python code within a class that is not in a method definition. It gets executed when the class is parsed by Python. – three_pineapples Apr 03 '16 at 23:48