diff --git a/tests/conftest.py b/tests/conftest.py index ab0a393e..4f0638a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,20 @@ install() # created in a session-scoped fixture; if it already exists (e.g., # the live_gui fixture also creates one), this call is a no-op or # fast (warmup already done). +# +# FIX (startup_speedup_20260606 sub-track 4 follow-up): The original +# code held `_warmup_app_controller` at module scope for the entire +# pytest session. When pytest exits, GC of the AppController triggers +# ThreadPoolExecutor.__del__ -> shutdown(wait=True). If warmup hasn't +# fully completed, shutdown blocks indefinitely, causing the batched +# test runner to hang after pytest exits. +# +# Fix: register an atexit handler that captures the pool reference +# directly (not the AppController) and shuts it down with wait=False. +# shutdown() is idempotent, so the subsequent shutdown(wait=True) in +# __del__ is a no-op. The pool reference is captured by closure so it +# survives even after the AppController is GC'd. +import atexit from src.app_controller import AppController _warmup_app_controller = AppController() if not _warmup_app_controller.wait_for_warmup(timeout=60.0): @@ -44,6 +58,12 @@ if not _warmup_app_controller.wait_for_warmup(timeout=60.0): RuntimeWarning, stacklevel=2, ) +_warmup_io_pool = getattr(_warmup_app_controller, "_io_pool", None) +def _shutdown_warmup_pool(pool: object = _warmup_io_pool) -> None: + if pool is not None: + try: pool.shutdown(wait=False) + except Exception: pass +atexit.register(_shutdown_warmup_pool) from src.gui_2 import App