diff --git a/src/ai_typewriter/ui/_tk_spawn.py b/src/ai_typewriter/ui/_tk_spawn.py index e159c56..c6424ed 100644 --- a/src/ai_typewriter/ui/_tk_spawn.py +++ b/src/ai_typewriter/ui/_tk_spawn.py @@ -1,23 +1,29 @@ -"""Helper to spawn Tk windows in dedicated *processes* with their own Tk root. +"""Helper to spawn Tk windows in dedicated **subprocesses** with their own Tk root. -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``. +Each call to ``spawn_tk_window()`` launches a fresh Python interpreter via +``subprocess.Popen`` that runs ``ai_typewriter.ui._tk_window_process``. +The child 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 child 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). +Communication +------------- +* **Parent → child** : pickled payload written to the child's **stdin**, + containing the fully-qualified class path, positional args, and keyword + args. Stdin is closed immediately after writing. +* **Child → parent** : pickled ``window.result`` written to the child's + **stdout** just before exit. The parent reads it via ``communicate()`` + in a background poller thread so the caller is never blocked. -Pickling constraints --------------------- -* ``window_class`` must be a class defined at module level (picklable). -* ``args`` / ``kwargs`` passed to the window constructor must be picklable. +Why subprocess instead of multiprocessing +----------------------------------------- +* No ``multiprocessing`` import overhead or start-method constraints. +* Clean, debuggable separation: the child is a standalone ``python -m`` + invocation. +* No pickling restrictions on function targets — the child imports the + window class by its module path. * ``SecureStore`` instances are **not** passed across the boundary; each child process creates its own (the dialog classes already default to ``SecureStore()`` when ``secure=None``). @@ -25,89 +31,21 @@ Pickling constraints from __future__ import annotations -import multiprocessing as mp -import queue +import os +import pickle +import subprocess +import sys import threading -import tkinter as tk 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 - - -# --------------------------------------------------------------------------- -# Child-process entry point (must be a top-level function → picklable) -# --------------------------------------------------------------------------- - -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 - - _closed = False - - 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: - if result_queue is not None and hasattr(win, "result"): - result_queue.put(win.result) - except Exception: - pass - try: - root.quit() - except tk.TclError: - pass - - win.bind("", _on_destroy) - win.protocol("WM_DELETE_WINDOW", lambda: win.destroy()) - - # -- 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 - - 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. +) -> subprocess.Popen: + """Spawn *window_class* in a dedicated **subprocess** with its own Tk root. Parameters ---------- @@ -122,41 +60,61 @@ def spawn_tk_window( 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. + or the child exits abnormally. **win_kwargs: Keyword arguments forwarded to the window constructor. Returns ------- - The ``multiprocessing.Process`` that was started (daemon=True). + The ``subprocess.Popen`` instance for the child process. """ - result_queue: mp.Queue | None = mp.Queue() if on_result is not None else None + # -- build the payload --------------------------------------------------- + payload = { + "class": f"{window_class.__module__}.{window_class.__qualname__}", + "args": win_args, + "kwargs": win_kwargs, + } + payload_bytes = pickle.dumps(payload) - p = mp.Process( - target=_run_tk_window_process, - args=(window_class, win_args, win_kwargs, result_queue), - daemon=True, - name="tk-window", + # -- ensure the child can find the ai_typewriter package ----------------- + env = os.environ.copy() + src_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..") ) - p.start() + existing = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = f"{src_dir}{os.pathsep}{existing}" if existing else src_dir + + # -- launch the child ---------------------------------------------------- + proc = subprocess.Popen( + [sys.executable, "-m", "ai_typewriter.ui._tk_window_process"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + + # Send the payload and close stdin so the child knows it's complete. + try: + proc.stdin.write(payload_bytes) # type: ignore[union-attr] + proc.stdin.close() # type: ignore[union-attr] + except BrokenPipeError: + # Child exited before reading — nothing to do. + pass + + # -- background result collection ---------------------------------------- + if on_result is not None: - 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) + stdout_data, stderr_data = proc.communicate() + if stdout_data: + result = pickle.loads(stdout_data) + else: + result = None + on_result(result) except Exception: on_result(None) threading.Thread(target=_poll, daemon=True, name="tk-result-poller").start() - return p + return proc diff --git a/src/ai_typewriter/ui/_tk_window_process.py b/src/ai_typewriter/ui/_tk_window_process.py new file mode 100644 index 0000000..f2d16dd --- /dev/null +++ b/src/ai_typewriter/ui/_tk_window_process.py @@ -0,0 +1,86 @@ +"""Entry point executed by ``subprocess.Popen`` to run a single Tk window. + +Reads a pickled payload from **stdin** that describes the window class to +instantiate, creates a fresh ``tk.Tk`` + ``tk.Toplevel``, runs the Tk +event loop, and writes the pickled ``window.result`` back to **stdout** +before exiting. + +This module is designed to be invoked as:: + + python -m ai_typewriter.ui._tk_window_process + +It is **not** imported by the parent process. +""" + +from __future__ import annotations + +import importlib +import pickle +import sys +import tkinter as tk + + +def main() -> None: + """Read args from stdin, run the Tk window, write result to stdout.""" + # -- decode the payload from stdin --------------------------------------- + payload = pickle.loads(sys.stdin.buffer.read()) + + mod_path, cls_name = payload["class"].rsplit(".", 1) + win_args: tuple = payload.get("args", ()) + win_kwargs: dict = payload.get("kwargs", {}) + + # -- import the window class dynamically --------------------------------- + mod = importlib.import_module(mod_path) + window_class = getattr(mod, cls_name) + + # -- create the Tk root + window ---------------------------------------- + root = tk.Tk() + root.withdraw() + try: + win = window_class(root, *win_args, **win_kwargs) + except Exception: + root.quit() + raise + + _closed = False + _result: object = None + + def _on_destroy(event: tk.Event) -> None: + nonlocal _closed, _result + if event.widget is not win: + return + if _closed: + return + _closed = True + try: + if hasattr(win, "result"): + _result = win.result + except Exception: + pass + try: + root.quit() + except tk.TclError: + pass + + win.bind("", _on_destroy) + win.protocol("WM_DELETE_WINDOW", lambda: win.destroy()) + + # -- 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 + + root.mainloop() + + # -- write the result back to the parent via stdout ---------------------- + sys.stdout.buffer.write(pickle.dumps(_result)) + sys.stdout.buffer.flush() + + +if __name__ == "__main__": + main()