refactor: subprocess.Popen au lieu de multiprocessing.Process pour les fenêtres Tk

- Nouveau module _tk_window_process.py : point d'entrée pour le subprocess
  (python -m ai_typewriter.ui._tk_window_process). Reçoit les args en
  pickle sur stdin, écrit window.result en pickle sur stdout.
- _tk_spawn.py réécrit avec subprocess.Popen. Payload picklé envoyé sur
  stdin, résultat lu via communicate() dans un thread poller.
- Plus besoin de multiprocessing.Queue ni de start_method.
- PYTHONPATH injecté automatiquement pour que le subprocess trouve le
  package ai_typewriter.
This commit is contained in:
Hermes Agent
2026-09-18 14:27:35 +02:00
parent 7fa401b287
commit 4adb88fd35
2 changed files with 157 additions and 113 deletions
+71 -113
View File
@@ -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("<Destroy>", _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
@@ -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("<Destroy>", _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()