From 8957c9a5bec74c01070878cabd7fabf2b7964a39 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 6 Jun 2026 21:35:05 -0400 Subject: [PATCH] fix(conftest): register atexit handler for non-blocking pool shutdown Fixes the run_tests_batched.py hang that occurs after batch 4. The original conftest (commit 52ea2693) stored _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 by then, the shutdown blocks indefinitely, causing the batched test runner to hang at the subprocess.run boundary. Fix: register an atexit handler that captures the _io_pool reference directly (default argument) and shuts it down with wait=False. The pool reference is captured by closure, surviving even after the AppController is GC'd. shutdown() is idempotent so the subsequent shutdown(wait=True) in __del__ is a no-op. This is part of sub-track 4 (warmup notification) cleanup; the conftest's wait_for_warmup behavior is preserved, only the exit-hang is fixed. --- tests/conftest.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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