diff --git a/src/ai_typewriter/app.py b/src/ai_typewriter/app.py index aaec0f3..c2390d3 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -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) ----- diff --git a/src/ai_typewriter/tray.py b/src/ai_typewriter/tray.py index a57044e..a84ba7a 100644 --- a/src/ai_typewriter/tray.py +++ b/src/ai_typewriter/tray.py @@ -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: diff --git a/src/ai_typewriter/ui/_tk_spawn.py b/src/ai_typewriter/ui/_tk_spawn.py index c24cd36..e159c56 100644 --- a/src/ai_typewriter/ui/_tk_spawn.py +++ b/src/ai_typewriter/ui/_tk_spawn.py @@ -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("", _on_destroy) - # WM_DELETE_WINDOW (the X button) just delegates to destroy() so that - # both paths funnel through the same 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("", _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