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:
Hermes Agent
2026-09-18 13:56:53 +02:00
parent e07f401b5c
commit 7fa401b287
3 changed files with 158 additions and 90 deletions
+6 -6
View File
@@ -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
View File
@@ -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:
+142 -73
View File
@@ -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