Repository navigation
Expand file tree
/
Copy pathgui_thread.py
More file actions
403 lines (352 loc) · 16.1 KB
/
Copy pathgui_thread.py
File metadata and controls
403 lines (352 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
"""Single Tk root owned by one dedicated GUI thread.
Tkinter is not thread-safe and a Tcl interpreter belongs to the thread that
created it, so the whole app shares one hidden root created here. Worker
threads never touch Tk directly: they hand a callable to ``call`` (blocking,
returns the result) or ``submit`` (fire-and-forget) and an ``after``-driven
pump runs it on the GUI thread.
Which thread that is depends on the OS. On Windows the root lives on a spawned
worker thread while pystray blocks the main one. On macOS both Aqua Tk and
AppKit demand thread 0, so ``main_thread`` mode designates the main thread as
the GUI thread instead: the caller runs :meth:`adopt_main_thread` then
:meth:`run_mainloop`, and the tray runs detached on the same loop. The
marshaling contract is identical in both modes — only the thread the pump ticks
on changes. See ``source/docs/macos-threading.md``.
On macOS one more rule applies, and breaking it aborts the process rather
than raising: **never call into Tcl/Tk from a Cocoa callback** — a tray menu
action, an NSNotification observer, an NSTimer block. ``_tkinter`` keeps a
single global ``tcl_tstate``, set while Python is inside a Tcl call and cleared
by ``LEAVE_TCL``. A Cocoa callback runs while ``mainloop()`` is already inside
``ENTER_TCL``, so a Tcl call made there clears that global with no
``ENTER_PYTHON`` frame left to restore it, and the next Tcl→Python callback
dies with ``Fatal Python error: PyEval_RestoreThread`` (issue #53). Cocoa
callbacks may therefore only touch Python state — ``submit`` queues, the pump
does the Tk work from inside the Tk loop where the invariant holds.
The keyboard listener thread must never call into this module; dialog
marshaling belongs to the expansion worker path only.
"""
import queue
import sys
import threading
import time
import tkinter as tk
from platform_support import tk_runs_on_main_thread
# Pump cadence. Low enough that a dialog feels instant while work is flowing.
PUMP_INTERVAL_MS = 40
# With nothing queued for a while the pump slows down: each wake costs about
# 0.3 ms in the full app, so 25 wakes a second kept an idle tray app at ~1.3%
# of a core (measured 0.16% at 250 ms). The trade-off is that the first GUI
# task after a quiet spell can wait up to one idle interval.
PUMP_IDLE_INTERVAL_MS = 200
PUMP_IDLE_AFTER_MS = 2000
_START_TIMEOUT_SECONDS = 10.0
# Posted to the GUI thread's message queue to wake Tcl's event wait. The
# message itself is ignored; waking is the point.
_WM_NULL = 0x0000
class GuiThread:
"""Owns the process-wide Tk root and marshals work onto its thread."""
def __init__(self, logger=None, main_thread=None):
self._logger = logger
self._queue = queue.Queue()
self._lock = threading.RLock()
self._ready = threading.Event()
self._thread = None
self._start_error = None
self._stopping = False
# Set by the teardown so the pump stops draining into a destroyed root.
self._destroyed = False
# Windows only: the GUI thread's native id and the waker signal.
self._native_thread_id = None
self._work_queued = threading.Event()
self._last_work = time.monotonic()
self._main_thread = (
tk_runs_on_main_thread() if main_thread is None else bool(main_thread)
)
self.root = None
# -----------------------------------------------------------------
# Lifecycle
# -----------------------------------------------------------------
def adopt_main_thread(self):
"""Make the calling thread the GUI thread and create the root there.
Main-thread mode only (macOS). Does not block: the caller is expected to
enter :meth:`run_mainloop` once the rest of startup is wired, and queued
work drains from the moment that loop begins.
"""
if not self._main_thread:
raise RuntimeError("adopt_main_thread requires main-thread mode")
if threading.current_thread() is not threading.main_thread():
raise RuntimeError("adopt_main_thread must run on the main thread")
with self._lock:
if self._stopping:
raise RuntimeError("GUI thread is shutting down")
if self.root is not None:
return True
self._start_error = None
self._thread = threading.current_thread()
try:
self.root = tk.Tk()
self.root.withdraw()
except Exception:
# Never leave a thread designated with no root behind it: call()
# would then run callables inline against ``None``.
with self._lock:
self._thread = None
raise
self._ready.set()
return True
def run_mainloop(self):
"""Block on the Tk loop until the root is destroyed. Main-thread mode only.
This is what ``run()`` enters on macOS in place of ``icon.run()``.
"""
if not self._main_thread:
raise RuntimeError("run_mainloop requires main-thread mode")
if self.root is None:
raise RuntimeError("adopt_main_thread must run before run_mainloop")
if threading.current_thread() is not self._thread:
raise RuntimeError("run_mainloop must run on the adopted GUI thread")
self._loop()
def ensure_started(self, timeout=_START_TIMEOUT_SECONDS):
"""Start the GUI thread if needed and block until the root exists."""
if self._main_thread:
with self._lock:
if self._stopping:
raise RuntimeError("GUI thread is shutting down")
if self.root is not None:
return True
if threading.current_thread() is not threading.main_thread():
# Loud on purpose: spawning a thread here is the exact
# unsupported configuration this mode exists to prevent.
raise RuntimeError(
"Tk root not started; on this platform it must be "
"adopted on the main thread before any worker call"
)
return self.adopt_main_thread()
with self._lock:
if self._stopping:
raise RuntimeError("GUI thread is shutting down")
if self._thread is None or not self._thread.is_alive():
self._ready.clear()
self._start_error = None
self._thread = threading.Thread(
target=self._run, name="tk-gui", daemon=True
)
self._thread.start()
if not self._ready.wait(timeout):
raise RuntimeError("Tk GUI thread did not become ready in time")
if self._start_error is not None:
raise self._start_error
return True
@property
def running(self):
if self._thread is None or not self._thread.is_alive():
return False
# In main-thread mode the designated thread outlives the root, so
# liveness alone would keep reporting a torn-down GUI as running.
return not self._main_thread or self.root is not None
def _run(self):
try:
self.root = tk.Tk()
self.root.withdraw()
except Exception as exc:
self._start_error = exc
self._ready.set()
return
if sys.platform == "win32":
self._native_thread_id = threading.get_native_id()
threading.Thread(
target=self._wake_while_queued, args=(threading.current_thread(),),
name="tk-gui-waker", daemon=True,
).start()
self._ready.set()
self._loop()
def _wake_while_queued(self, gui_thread):
"""Keep the pump running on Windows even when Tcl's timer goes deaf.
After a process has created enough Tk interpreters on different
threads, a new interpreter's event wait can stop honouring timer
deadlines: the pump's ``after`` timer stays armed and overdue while
``mainloop`` sleeps until a window message arrives (reproduced in
roughly 1 of 15 fresh interpreters past the 24th; never with one
interpreter per process). While work is queued, post a no-op message
each pump interval; by then the pump timer is due and Tcl runs it. A
healthy loop drains first, so this usually posts nothing.
"""
import ctypes
post = ctypes.windll.user32.PostThreadMessageW
interval = PUMP_INTERVAL_MS / 1000
while gui_thread.is_alive():
# Idle: only wake to notice the GUI thread exiting.
if not self._work_queued.wait(1.0):
continue
self._work_queued.clear()
# Work queued after this clear sets the event again and is picked
# up by the next outer iteration.
time.sleep(interval)
while gui_thread.is_alive() and not self._queue.empty():
post(self._native_thread_id, _WM_NULL, 0, 0)
time.sleep(interval)
def _loop(self):
try:
self._pump()
self.root.mainloop()
except Exception as exc:
self._log(f"Loop da thread de GUI encerrado com erro: {exc}")
finally:
try:
self.root.destroy()
except Exception:
pass
self.root = None
# An abnormal exit strands queued callers exactly like stop()
# would — and a caller blocked in call(timeout=None) is holding
# the dialog lock, which would refuse every later dialog.
self._fail_pending()
def _teardown(self, root):
# destroy(), not quit(): mainloop exits when the window count hits
# zero, which is deterministic. quit() relies on _tkinter's quit
# flag, which proved unreliable once the process has had earlier
# Tk interpreters (observed on 3.14: stop() hung until the join
# timeout while the pump kept ticking).
self._destroyed = True
root.destroy()
def stop(self, timeout=5.0):
"""Tear the root down and join the GUI thread."""
with self._lock:
if not self.running:
self._stopping = True
self._fail_pending()
return
self._stopping = True
thread = self._thread
if self._main_thread:
self._stop_main_thread(timeout)
return
# Cannot use call(): ensure_started() refuses once _stopping is set.
self._queue.put((self._teardown, None, None))
self._work_queued.set()
thread.join(timeout)
self._fail_pending()
def _stop_main_thread(self, timeout):
"""Tear the root down when the GUI thread is the main thread.
There is no thread to join — the main thread is the process — so the
destroy is awaited through the pump instead. Draining the queue before
it has run would swallow the teardown itself.
"""
if threading.current_thread() is self._thread:
# A tray menu callback lands here: on macOS pystray dispatches it
# on the main thread, from inside the very loop ``mainloop()`` is
# pumping. Destroying the interpreter mid-dispatch is not safe, so
# the teardown goes through the queue; ``_loop`` fails the pending
# queue when it unwinds.
#
# The queue rather than a raw ``root.after``: this runs in a Cocoa
# frame, and scheduling a Tcl timer from there is itself the call
# that poisons ``tcl_tstate`` and aborts the loop (issue #53).
self._queue.put((self._teardown, None, None))
return
done = threading.Event()
self._queue.put((self._teardown, {}, done))
if not done.wait(timeout):
self._log("Root do Tk não confirmou o encerramento a tempo")
self._fail_pending()
def _fail_pending(self):
"""Wake workers whose queued calls will never run.
Items still queued after the loop exits would otherwise leave their
callers blocked forever in ``call(timeout=None)`` — ``done`` is only
set by the GUI thread that no longer exists.
"""
while True:
try:
_func, box, done = self._queue.get_nowait()
except queue.Empty:
return
if box is not None:
box["error"] = RuntimeError(
"GUI thread stopped before the call could run"
)
if done is not None:
done.set()
# -----------------------------------------------------------------
# Marshaling
# -----------------------------------------------------------------
def call(self, func, timeout=None):
"""Run ``func(root)`` on the GUI thread and return its result.
Blocks the calling thread until ``func`` returns, re-raising whatever
it raised. ``timeout=None`` waits indefinitely, which is what a modal
dialog needs — the user decides when it closes.
"""
self.ensure_started()
if threading.current_thread() is self._thread:
return func(self.root)
box = {}
done = threading.Event()
self._queue.put((func, box, done))
self._work_queued.set()
if not done.wait(timeout):
raise TimeoutError("GUI call did not complete in time")
if "error" in box:
raise box["error"]
return box.get("value")
def submit(self, func):
"""Queue ``func(root)`` on the GUI thread without waiting for it.
Main-thread mode never runs it inline, even when the caller *is* the GUI
thread. There the caller is a tray menu callback — a Cocoa frame, not a
Tk one — and any Tcl call made from there poisons ``tcl_tstate`` for the
running ``mainloop``, aborting the process on its next timer (issue #53,
and the module docstring for the mechanism). Queuing costs one pump tick
and nothing is lost. ``call`` cannot do the same: its caller blocks on
the result, and on the GUI thread that would deadlock.
"""
self.ensure_started()
if threading.current_thread() is self._thread and not self._main_thread:
func(self.root)
return
self._queue.put((func, None, None))
self._work_queued.set()
def _pump(self):
if self.root is None:
return
# Reschedule before draining, and unconditionally: a queued callable
# may open a modal dialog and block here inside a nested event loop,
# and the next tick is what keeps later requests (and the manager
# window) responsive. Gating this on the stop flag would also risk
# dropping the quit sentinel itself. The loop dies with the root.
now = time.monotonic()
if not self._queue.empty():
self._last_work = now
busy = (now - self._last_work) * 1000 < PUMP_IDLE_AFTER_MS
try:
self.root.after(PUMP_INTERVAL_MS if busy else PUMP_IDLE_INTERVAL_MS, self._pump)
except Exception:
return
while True:
try:
item = self._queue.get_nowait()
except queue.Empty:
return
self._invoke(item)
if self._destroyed:
# The teardown was one of the queued items. Anything behind it
# would run against a destroyed root; ``_loop`` fails those
# callers as it unwinds.
return
def _invoke(self, item):
func, box, done = item
try:
value = func(self.root)
if box is not None:
box["value"] = value
except Exception as exc:
if box is None:
self._log(f"Erro em tarefa de GUI: {exc}")
else:
box["error"] = exc
finally:
if done is not None:
done.set()
def _log(self, message):
if self._logger is not None:
try:
self._logger.error(message)
return
except Exception:
pass
print(message)