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:
@@ -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()
|
||||
Reference in New Issue
Block a user