refactor: multiprocessing.Process au lieu de threading.Thread pour les fenêtres Tk
- _tk_spawn.py : chaque fenêtre tourne dans un Process séparé (spawn, pas fork) avec sa propre instance tk.Tk. Résultats renvoyés au parent via multiprocessing.Queue + thread poller. - API changée : spawn_tk_window(Class, *args, on_result=..., **kwargs) au lieu d'une lambda factory — nécessaire pour la picklabilité. - SecureStore n'est plus passé aux processus fils (non picklable) ; chaque dialogue crée le sien (déjà le comportement par défaut). - TrayApp allégé : plus de self.secure, plus d'import SecureStore.
This commit is contained in:
@@ -7,14 +7,14 @@ disponibles pour le développement / le test :
|
||||
imprime la réponse sans intercepter le clavier.
|
||||
- ``python main.py`` : lance l'icône de la zone de notification.
|
||||
|
||||
Architecture (threads)
|
||||
----------------------
|
||||
* **Thread principal** : capture du raccourci global (Ctrl+Alt+A) et
|
||||
Architecture (processus)
|
||||
------------------------
|
||||
* **Processus principal** : capture du raccourci global (Ctrl+Alt+A) et
|
||||
traitement des requêtes IA. Aucune boucle Tk ne tourne ici.
|
||||
* **Thread UI (tray)** : icône pystray dans la zone de notification.
|
||||
* **Fenêtres** : chaque dialogue (logs, profil, authentification, …) est
|
||||
lancé dans son **propre thread** avec sa **propre instance Tk** racine.
|
||||
Aucune racine Tk partagée — une fenêtre = un thread = un ``tk.Tk``.
|
||||
lancé dans son **propre processus** avec sa **propre instance Tk** racine.
|
||||
Aucune racine Tk partagée — une fenêtre = un processus = un ``tk.Tk``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -79,7 +79,7 @@ def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
|
||||
|
||||
- Le thread principal enregistre le raccourci global et attend l'arrêt.
|
||||
- L'icône de notification (pystray) tourne dans un thread dédié.
|
||||
- Chaque fenêtre Tk est créée dans son propre thread (voir
|
||||
- Chaque fenêtre Tk est créée dans son propre processus (voir
|
||||
``ui._tk_spawn.spawn_tk_window``).
|
||||
"""
|
||||
# -- raccourci global (sur le thread principal, via le hook keyboard) -----
|
||||
|
||||
+10
-11
@@ -11,9 +11,10 @@ seule une icône dans la zone de notification. Le menu de l'icône permet :
|
||||
Architecture
|
||||
------------
|
||||
* **Thread UI** : exécute ``pystray.Icon.run()`` (boucle GTK / appindicator).
|
||||
* **Fenêtres** : chaque action du menu crée une fenêtre Tk dans un thread
|
||||
dédié avec sa propre racine ``tk.Tk`` (via ``ui._tk_spawn.spawn_tk_window``).
|
||||
Il n'y a **aucune** racine Tk partagée.
|
||||
* **Fenêtres** : chaque action du menu crée une fenêtre Tk dans un
|
||||
**processus** dédié avec sa propre racine ``tk.Tk`` (via
|
||||
``ui._tk_spawn.spawn_tk_window``). Il n'y a **aucune** racine Tk
|
||||
partagée.
|
||||
* **Résultats** : les dialogues qui retournent une valeur (ProfileDialog,
|
||||
ModelPicker) passent leur résultat via un callback exécuté à la fermeture.
|
||||
"""
|
||||
@@ -25,7 +26,6 @@ import threading
|
||||
from typing import Callable
|
||||
|
||||
from .config import ConfigStore
|
||||
from .credentials import SecureStore
|
||||
from .ui._tk_spawn import spawn_tk_window
|
||||
from .ui.auth_dialog import AuthDialog
|
||||
from .ui.logs_window import LogsWindow
|
||||
@@ -40,13 +40,11 @@ class TrayApp:
|
||||
def __init__(
|
||||
self,
|
||||
store: ConfigStore,
|
||||
secure: SecureStore | None = None,
|
||||
icon_factory: Callable | None = None,
|
||||
menu_factory: Callable | None = None,
|
||||
stop_event: threading.Event | None = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.secure = secure or SecureStore()
|
||||
self._icon = None
|
||||
self._icon_factory = icon_factory
|
||||
self._menu_factory = menu_factory
|
||||
@@ -75,7 +73,7 @@ class TrayApp:
|
||||
# -- actions du menu -------------------------------------------------------
|
||||
|
||||
def _show_logs(self) -> None:
|
||||
spawn_tk_window(lambda root: LogsWindow(root))
|
||||
spawn_tk_window(LogsWindow)
|
||||
|
||||
def _edit_profile(self, name: str | None = None) -> None:
|
||||
"""Ouvre l'éditeur du profil *name*, ou le profil actif si ``None``."""
|
||||
@@ -93,7 +91,8 @@ class TrayApp:
|
||||
LOG.exception("Impossible d'enregistrer le profil : %s", exc)
|
||||
|
||||
spawn_tk_window(
|
||||
lambda root: ProfileDialog(root, existing=target, secure=self.secure),
|
||||
ProfileDialog,
|
||||
existing=target,
|
||||
on_result=on_done,
|
||||
)
|
||||
|
||||
@@ -111,7 +110,7 @@ class TrayApp:
|
||||
LOG.exception("Impossible d'ajouter le profil : %s", exc)
|
||||
|
||||
spawn_tk_window(
|
||||
lambda root: ProfileDialog(root, existing=None, secure=self.secure),
|
||||
ProfileDialog,
|
||||
on_result=on_done,
|
||||
)
|
||||
|
||||
@@ -132,7 +131,7 @@ class TrayApp:
|
||||
LOG.debug("update_menu() a échoué", exc_info=True)
|
||||
|
||||
def _manage_auth(self) -> None:
|
||||
spawn_tk_window(lambda root: AuthDialog(root, self.secure))
|
||||
spawn_tk_window(AuthDialog)
|
||||
|
||||
# -- construction de l'icône ----------------------------------------------
|
||||
|
||||
@@ -229,7 +228,7 @@ class TrayApp:
|
||||
|
||||
Contrairement à l'ancienne architecture, on n'a **plus** besoin de
|
||||
déférer l'exécution vers un thread Tk principal via ``after()`` :
|
||||
chaque fenêtre crée son propre thread Tk.
|
||||
chaque fenêtre tourne dans son propre processus Tk.
|
||||
"""
|
||||
|
||||
def handler(icon, item) -> None:
|
||||
|
||||
@@ -1,93 +1,162 @@
|
||||
"""Helper to spawn Tk windows in dedicated threads with their own Tk root.
|
||||
"""Helper to spawn Tk windows in dedicated *processes* with their own Tk root.
|
||||
|
||||
Each call to ``spawn_tk_window()`` creates a fresh ``tk.Tk`` (withdrawn),
|
||||
instantiates the window as a ``tk.Toplevel`` of that root, and runs the
|
||||
Tk event loop in a brand-new daemon thread. When the toplevel is closed
|
||||
(programmatically or via the window manager), the root quits and the
|
||||
thread exits cleanly.
|
||||
Each call to ``spawn_tk_window()`` starts a brand-new ``multiprocessing.Process``
|
||||
that creates its own ``tk.Tk`` (withdrawn), instantiates the window as a
|
||||
``tk.Toplevel`` of that root, and runs the Tk event loop. When the toplevel
|
||||
is closed the root quits, the process exits, and the optional *on_result*
|
||||
callback is invoked **in the parent process** with ``window.result``.
|
||||
|
||||
Why processes instead of threads
|
||||
--------------------------------
|
||||
* True isolation: a crashed / stuck window cannot corrupt other windows
|
||||
or the tray icon.
|
||||
* No GIL contention between independent Tk event loops.
|
||||
* The ``spawn`` start method is forced so that the child starts from a
|
||||
clean interpreter (``fork`` is unsafe because the parent has threads).
|
||||
|
||||
Pickling constraints
|
||||
--------------------
|
||||
* ``window_class`` must be a class defined at module level (picklable).
|
||||
* ``args`` / ``kwargs`` passed to the window constructor must be picklable.
|
||||
* ``SecureStore`` instances are **not** passed across the boundary; each
|
||||
child process creates its own (the dialog classes already default to
|
||||
``SecureStore()`` when ``secure=None``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from typing import Callable
|
||||
from typing import Any, Callable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Force the safe start method early (before any Process is created).
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
if mp.get_start_method(allow_none=True) != "spawn":
|
||||
mp.set_start_method("spawn", force=True)
|
||||
except RuntimeError:
|
||||
# Already set by another module — nothing to do.
|
||||
pass
|
||||
|
||||
|
||||
def spawn_tk_window(
|
||||
window_factory: Callable[[tk.Tk], tk.Toplevel],
|
||||
on_result: Callable[[object], None] | None = None,
|
||||
) -> threading.Thread:
|
||||
"""Spawn *window_factory* in a dedicated thread with its own Tk root.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Child-process entry point (must be a top-level function → picklable)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Parameters
|
||||
----------
|
||||
window_factory:
|
||||
Called with a fresh withdrawn ``tk.Tk`` as the single argument.
|
||||
Must return a ``tk.Toplevel`` that will be presented to the user.
|
||||
on_result:
|
||||
If provided, called **after** the Tk mainloop exits with the
|
||||
value of ``window.result`` (or ``None`` when the attribute is
|
||||
absent). The callback runs on the spawned thread, not on the
|
||||
caller's thread.
|
||||
def _run_tk_window_process(
|
||||
window_class: type,
|
||||
win_args: tuple[Any, ...],
|
||||
win_kwargs: dict[str, Any],
|
||||
result_queue: mp.Queue | None,
|
||||
) -> None:
|
||||
"""Entry point executed inside the child process."""
|
||||
root = tk.Tk()
|
||||
root.withdraw() # never show the bare root window
|
||||
try:
|
||||
win = window_class(root, *win_args, **win_kwargs)
|
||||
except Exception:
|
||||
root.quit()
|
||||
raise
|
||||
|
||||
Returns
|
||||
-------
|
||||
The ``threading.Thread`` that was started (daemon=True).
|
||||
"""
|
||||
result_container: list[object] = []
|
||||
_closed = False
|
||||
|
||||
def _run() -> None:
|
||||
root = tk.Tk()
|
||||
root.withdraw() # never show the bare root window
|
||||
def _on_destroy(event: tk.Event) -> None:
|
||||
nonlocal _closed
|
||||
if event.widget is not win:
|
||||
return
|
||||
if _closed:
|
||||
return
|
||||
_closed = True
|
||||
# Snapshot result before the widget is torn down.
|
||||
try:
|
||||
win = window_factory(root)
|
||||
if result_queue is not None and hasattr(win, "result"):
|
||||
result_queue.put(win.result)
|
||||
except Exception:
|
||||
root.quit()
|
||||
raise
|
||||
|
||||
_closed = False
|
||||
|
||||
def _on_destroy(event: tk.Event) -> None:
|
||||
"""Fires for every destroyed widget — only act for the toplevel."""
|
||||
nonlocal _closed
|
||||
if event.widget is not win:
|
||||
return
|
||||
if _closed:
|
||||
return
|
||||
_closed = True
|
||||
# Snapshot the result attribute before the Python wrapper is gone.
|
||||
try:
|
||||
if hasattr(win, "result"):
|
||||
result_container.append(win.result)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
root.quit()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
win.bind("<Destroy>", _on_destroy)
|
||||
# WM_DELETE_WINDOW (the X button) just delegates to destroy() so that
|
||||
# both paths funnel through the same <Destroy> handler above.
|
||||
win.protocol("WM_DELETE_WINDOW", lambda: win.destroy())
|
||||
|
||||
# -- present the window ------------------------------------------------
|
||||
pass
|
||||
try:
|
||||
win.deiconify()
|
||||
win.lift()
|
||||
win.attributes("-topmost", True)
|
||||
win.after(200, lambda: win.attributes("-topmost", False))
|
||||
win.focus_force()
|
||||
root.quit()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
root.mainloop()
|
||||
win.bind("<Destroy>", _on_destroy)
|
||||
win.protocol("WM_DELETE_WINDOW", lambda: win.destroy())
|
||||
|
||||
if on_result is not None:
|
||||
result = result_container[0] if result_container else None
|
||||
on_result(result)
|
||||
# -- present the window ------------------------------------------------
|
||||
try:
|
||||
win.deiconify()
|
||||
win.lift()
|
||||
win.attributes("-topmost", True)
|
||||
win.after(200, lambda: win.attributes("-topmost", False))
|
||||
win.focus_force()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="tk-window")
|
||||
t.start()
|
||||
return t
|
||||
root.mainloop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def spawn_tk_window(
|
||||
window_class: type,
|
||||
*win_args: Any,
|
||||
on_result: Callable[[object], None] | None = None,
|
||||
**win_kwargs: Any,
|
||||
) -> mp.Process:
|
||||
"""Spawn *window_class* in a dedicated **process** with its own Tk root.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
window_class:
|
||||
A ``tk.Toplevel`` subclass (defined at module level). Its
|
||||
``__init__`` must accept a ``parent`` (``tk.Tk``) as the first
|
||||
positional argument.
|
||||
*win_args:
|
||||
Extra positional arguments forwarded to the window constructor
|
||||
(after the parent).
|
||||
on_result:
|
||||
If provided, called **in the parent process** (on a short-lived
|
||||
poller thread) with the value of ``window.result`` once the
|
||||
window closes. ``None`` is passed when the attribute is absent
|
||||
or the process exits abnormally.
|
||||
**win_kwargs:
|
||||
Keyword arguments forwarded to the window constructor.
|
||||
|
||||
Returns
|
||||
-------
|
||||
The ``multiprocessing.Process`` that was started (daemon=True).
|
||||
"""
|
||||
result_queue: mp.Queue | None = mp.Queue() if on_result is not None else None
|
||||
|
||||
p = mp.Process(
|
||||
target=_run_tk_window_process,
|
||||
args=(window_class, win_args, win_kwargs, result_queue),
|
||||
daemon=True,
|
||||
name="tk-window",
|
||||
)
|
||||
p.start()
|
||||
|
||||
if on_result is not None and result_queue is not None:
|
||||
# Poll the result queue from a background thread so the caller
|
||||
# (pystray callback) is never blocked.
|
||||
def _poll() -> None:
|
||||
try:
|
||||
while p.is_alive() or not result_queue.empty():
|
||||
try:
|
||||
result = result_queue.get(timeout=0.3)
|
||||
on_result(result)
|
||||
return
|
||||
except queue.Empty:
|
||||
pass
|
||||
# Process exited without sending a result.
|
||||
on_result(None)
|
||||
except Exception:
|
||||
on_result(None)
|
||||
|
||||
threading.Thread(target=_poll, daemon=True, name="tk-result-poller").start()
|
||||
|
||||
return p
|
||||
|
||||
Reference in New Issue
Block a user