Sunday, 29 January 2023

Python thread calling won't finish when closing tkinter application

I am making a timer using tkinter in python. The widget simply has a single button. This button doubles as the element displaying the time remaining. The timer has a thread that simply updates what time is shown on the button.

The thread simply uses a while loop that should stop when an event is set. When the window is closed, I use protocol to call a function that sets this event then attempts to join the thread. This works most of the time. However, if I close the program just as a certain call is being made, this fails and the thread continues after the window has been closed.

I'm aware of the other similar threads about closing threads when closing a tkinter window. But these answers are old, and I would like to avoid using thread.stop() if possible.

I tried reducing this as much as possible while still showing my intentions for the program.

import tkinter as tk
from tkinter import TclError, ttk
from datetime import timedelta
import time
import threading
from threading import Event

def strfdelta(tdelta):
    # Includes microseconds
    hours, rem = divmod(tdelta.seconds, 3600)
    minutes, seconds = divmod(rem, 60)
    return str(hours).rjust(2, '0') + ":" + str(minutes).rjust(2, '0') + \
           ":" + str(seconds).rjust(2, '0') + ":" + str(tdelta.microseconds).rjust(6, '0')[0:2]

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.is_running = False
        is_closing = Event()
        self.start_time = timedelta(seconds=4, microseconds=10, minutes=0, hours=0)
        self.current_start_time = self.start_time
        self.time_of_last_pause = time.time()
        self.time_of_last_unpause = None
        # region guisetup
        self.time_display = None
        self.geometry("320x110")
        self.title('Replace')
        self.resizable(False, False)
        box1 = self.create_top_box(self)
        box1.place(x=0, y=0)
        # endregion guisetup
        self.timer_thread = threading.Thread(target=self.timer_run_loop, args=(is_closing, ))
        self.timer_thread.start()

        def on_close():  # This occasionally fails when we try to close.
            is_closing.set()  # This used to be a boolean property self.is_closing. Making it an event didn't help.
            print("on_close()")
            try:
                self.timer_thread.join(timeout=2)
            finally:
                if self.timer_thread.is_alive():
                    self.timer_thread.join(timeout=2)
                    if self.timer_thread.is_alive():
                        print("timer thread is still alive again..")
                    else:
                        print("timer thread is finally finished")
                else:
                    print("timer thread finished2")
            self.destroy()  # https://stackoverflow.com/questions/111155/how-do-i-handle-the-window-close-event-in-tkinter
        self.protocol("WM_DELETE_WINDOW", on_close)

    def create_top_box(self, container):
        box = tk.Frame(container, height=110, width=320)
        box_m = tk.Frame(box, bg="blue", width=320, height=110)
        box_m.place(x=0, y=0)
        self.time_display = tk.Button(box_m, text=strfdelta(self.start_time), command=self.toggle_timer_state)
        self.time_display.place(x=25, y=20)
        return box

    def update_shown_time(self, time_to_show: timedelta = None):
        print("timer_run_loop must finish. flag 0015")  # If the window closes at this point, everything freezes
        self.time_display.configure(text=strfdelta(time_to_show))
        print("timer_run_loop must finish. flag 016")

    def toggle_timer_state(self):
        # update time_of_last_unpause if it has never been set
        if not self.is_running and self.time_of_last_unpause is None:
            self.time_of_last_unpause = time.time()
        if self.is_running:
            self.pause_timer()
        else:
            self.start_timer_running()

    def pause_timer(self):
        pass  # Uses self.time_of_last_unpause, Alters self.is_running, self.time_of_last_pause, self.current_start_time

    def timer_run_loop(self, event):
        while not event.is_set():
            if not self.is_running:
                print("timer_run_loop must finish. flag 008")
                self.update_shown_time(self.current_start_time)
            print("timer_run_loop must finish. flag 018")
        print("timer_run_loop() ending")

    def start_timer_running(self):
        pass  # Uses self.current_start_time; Alters self.is_running, self.time_of_last_unpause

if __name__ == "__main__":
    app = App()
    app.mainloop()

You don't even have to press the button for this bug to manifest, but it does take trail and error. I just run it and hit alt f4 until it happens.

If you run this and encounter the problem, you will see that "timer_run_loop must finish. flag 0015" is the last thing printed before we check if the thread has ended. That means, self.time_display.configure(text=strfdelta(time_to_show)) hasn't finished yet. I think closing the tkinter window while a thread is using this tkinter button inside of it is somehow causing a problem.

There seems to be very little solid documentation about the configure method in tkinter. Python's official documention of tkinter mentions the function only in passing. It's just used as a read-only dictionary.
A tkinter style class gets a little bit of detail about it's configure method, but this is unhelpful.
The tkdocs lists configure aka config as one of the methods available for all widgets.
This tutorial article seems to be the only place that shows the function actually being used. But it doesn't mention any possible problems or exceptions the method could encounter.

Is there some resource sharing pattern I'm not using? Or is there a better way to end this thread?



from Python thread calling won't finish when closing tkinter application

No comments:

Post a Comment