Tkinter sync loop: re-entrant update(), worker-thread widget calls, and unsafe shutdown
AssetTrack is a Tkinter/ttk desktop client used by 12 warehouse clerks. Its main window has a ttk.Treeview populated with 8,000 asset rows and a Sync button whose handler fetches 500 records from an HTTPS API, one requests.get() per record, with no timeout. Clerks report the window is 'Not responding' for 45-90 seconds and the progress bar stays at 0%. A developer added root.update() inside the loop, which made the bar move but introduced random crashes and duplicated clicks. A later attempt moved the loop into a threading.Thread; now the worker calls self.status_var.set(...), self.progress['value'] = n, and tree.insert(...) directly. The app intermittently dies with RuntimeError: main thread is not in main loop, and closing the window during a sync sometimes leaves a Python process running or raises TclError: invalid command name ".!frame.!progressbar". You are asked to make syncing responsive, cancellable and safe to close.
1. Explain why `root.update()` is not a fix and why the worker's direct widget calls fail. Redesign the interaction using Tkinter's event model, with a concrete pattern.
Read the worked answer
root.update() processes all pending events, including button clicks and window-close events, from inside your loop. It does not make the work asynchronous; it re-enters the event loop, so a second Sync click can start a second loop, a close event can destroy widgets while the handler is still using them, and the handler can recurse. That explains the duplicate clicks and random crashes. The thread version fails for a simpler reason: Tkinter is not thread-safe. Only the thread that created the interpreter may call Tcl/Tk; StringVar.set, progress['value'] = n and tree.insert from a worker thread can raise RuntimeError: main thread is not in main loop or corrupt the interpreter silently.
The pattern is: worker threads do I/O and put plain data on a queue.Queue; the main thread owns every widget operation. Schedule a poller with root.after(100, poll):
def poll(self):
try:
while True:
kind, payload = self.q.get_nowait()
if kind == 'progress':
self.progress.configure(value=payload)
elif kind == 'row':
self.tree.insert('', 'end', values=payload)
elif kind == 'done':
self.sync_btn.configure(state='normal')
return
except queue.Empty:
pass
self._poll_id = self.root.after(100, poll)
Start the poller once, keep its id, and cancel it with after_cancel on shutdown. Use a bounded queue or coalesce progress messages if the worker is much faster than 10 Hz.
2. Design cancellation and window-close behavior. Why does `TclError: invalid command name` appear, and how do you avoid both a frozen UI and a lingering process?
Read the worked answer
Shutdown has three requirements: stop assigning new work, stop the worker without freezing the UI, and do not let callbacks touch destroyed widgets. Use a threading.Event for cancellation and check it between requests, not only at the top of the loop. Set finite network timeouts (timeout=(3.05, 30)) so a worker cannot sit in a socket read forever; a cancellation flag cannot interrupt a blocking call by itself.
WM_DELETE_WINDOW should run a short handler on the main thread: set the event, disable the Sync button, change the label to 'Cancelling...', and return. Do not call join() there - joining blocks the event loop and the UI freezes again. Let the worker post a final ('done', None) or ('cancelled', None) message; the poller sees it, cancels its own after id, and then calls root.destroy(). If you must wait, poll thread.is_alive() with after(100, ...) and destroy only when it is false. Make the worker non-daemon and join it briefly after the main loop exits, or in a finally, so the process does not linger; daemon threads are a fallback, not a shutdown protocol.
The TclError: invalid command name is exactly the destroyed-widget case: a queued after callback fired after destroy(). Cancel pending after ids, guard with if not self.root.winfo_exists(): return, and ignore late queue messages after the closing flag is set. Also disable the button when a sync starts; that removes duplicate clicks without update().
3. The tree must show 8,000 rows after a sync. Inserting them one by one in the main thread makes the window sluggish again. What are the practical options, and what should be measured first?
Read the worked answer
Inserting 8,000 rows one at a time is slow because every insert triggers geometry and layout work. Do not call update() per row. Options, in order of preference: (1) fetch pages in the worker, but insert in the main thread in chunks of 200-500 rows scheduled with after(1, ...), so the UI breathes between chunks; (2) if a bulk refresh is acceptable, detach the tree's existing root children, perform the inserts, then reattach or delete the old roots, reducing repeated layout against a populated tree; (3) build a list of tuples and call tree.insert('', 'end', values=row) in a loop inside one chunk, which is still 8,000 Tcl calls but avoids Python-side per-row callbacks.
Measure before optimizing: time.perf_counter() around the insert phase. If the bottleneck is the HTTPS calls, none of this matters; if it is Tk, chunking improves perceived responsiveness but not total time. Avoid update_idletasks() inside the loop as a substitute for chunking. For a read-only view, consider inserting only the current page; virtualized tables are not built into ttk.