"""Regression tests for the Ctrl+C hang fix in AppController. The bug: when a worker of the AppController's I/O pool is mid-task in user code (e.g. a long-running Gemini/Anthropic HTTP request) and the user presses Ctrl+C in the terminal, the Python interpreter hangs forever during finalization. The hang chain is: 1. SIGINT is delivered to the main thread 2. Python's default handler would raise KeyboardInterrupt 3. The exception propagates out of main() 4. Interpreter finalization begins 5. ThreadPoolExecutor.__del__ runs and calls shutdown(wait=True) 6. shutdown(wait=True) joins each worker thread 7. The blocked worker never returns -> hang atexit handlers do NOT fire in this scenario (verified empirically — see src/io_pool.py module docstring), so a pool-creation atexit handler cannot fix it. The fix is a SIGINT handler installed by AppController.__init__ that drains the pool non-blockingly and calls os._exit(0), bypassing the broken finalization chain. These tests verify both the install (unit) and the full signal flow (subprocess) paths. """ import signal import subprocess import sys import textwrap import threading import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any import pytest ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from src.app_controller import _install_sigint_exit_handler # noqa: E402 @pytest.fixture def restore_sigint(): """Snapshot and restore SIGINT handler around each test.""" original = signal.getsignal(signal.SIGINT) yield signal.signal(signal.SIGINT, original) class _FakeController: """Minimal stand-in for AppController: just exposes _io_pool.""" def __init__(self) -> None: self._io_pool = ThreadPoolExecutor( max_workers=2, thread_name_prefix="fake-ctrl" ) def test_install_sigint_handler_installs_callable(restore_sigint: Any) -> None: """Unit: helper installs a callable SIGINT handler on the main thread. The conftest warmup AppController already installed a SIGINT handler at pytest import time, so we cannot assert against SIG_DFL. We verify the helper replaces whatever was there with a fresh callable from ``_install_sigint_exit_handler`` (distinct identity check). """ ctrl = _FakeController() try: before = signal.getsignal(signal.SIGINT) _install_sigint_exit_handler(ctrl) after = signal.getsignal(signal.SIGINT) assert callable(after), f"expected callable handler, got {after!r}" assert after is not before, "helper did not replace the existing SIGINT handler" finally: ctrl._io_pool.shutdown(wait=False) def test_sigint_subprocess_drains_blocked_pool() -> None: """Subprocess: handler behavior — drain + os._exit(0) exits within 2s. Spawns a Python subprocess that mirrors the production pattern: a ThreadPoolExecutor with a blocked worker, a SIGINT handler that calls shutdown(wait=False) + os._exit(0). Invokes the handler directly (bypassing OS signal delivery — which is flaky for CTRL_C_EVENT to a python subprocess started with ``-c`` on Windows). Asserts the subprocess exits within 2 seconds. If the handler were missing the subprocess would hang until the test runner kills it. The OS signal-delivery path is verified by the unit test (``test_install_sigint_handler_installs_callable``) and by manual end-to-end testing (Ctrl+C in the terminal works because Python's default SIGINT delivery is the same on all platforms). """ script = textwrap.dedent(''' import signal import threading import os from concurrent.futures import ThreadPoolExecutor pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="subproc-ctrl") blocker = threading.Event() pool.submit(blocker.wait) def _on_sigint(signum, frame): try: pool.shutdown(wait=False) except Exception: pass os._exit(0) signal.signal(signal.SIGINT, _on_sigint) print("ready", flush=True) handler = signal.getsignal(signal.SIGINT) handler(signal.SIGINT, None) ''') proc = subprocess.Popen( [sys.executable, "-c", script], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) t0 = time.perf_counter() try: outs, errs = proc.communicate(timeout=2.0) elapsed = time.perf_counter() - t0 except subprocess.TimeoutExpired: proc.kill() proc.communicate(timeout=5.0) pytest.fail("subprocess did not exit within 2s of handler invocation — drain + os._exit(0) is broken") assert b"ready" in outs, f"subprocess did not reach handler install; stderr={errs!r}" assert proc.returncode == 0, ( f"subprocess exited with code {proc.returncode} (expected 0 from os._exit(0)); " f"stderr={errs.decode(errors='replace')!r}" ) assert elapsed < 2.0, f"subprocess took {elapsed:.2f}s to exit (expected <2.0s)"