Private
Public Access
0
0
Files
manual_slop/tests/test_app_controller_sigint.py
T
ed 62b260d1f2 test(app_controller_sigint): update _FakeController for Phase 6 Result-based helpers
The Phase 6 Group 6.1 migration changed _install_sigint_exit_handler
to call controller._install_signal_handler_result(handler) and
controller._shutdown_io_pool_result(). The _FakeController test stub
needs to provide these new helpers to maintain the test contract.
2026-06-19 16:24:01 -04:00

160 lines
5.5 KiB
Python

"""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 + the
Result-based signal-handler helpers added in Phase 6 Group 6.1."""
def __init__(self) -> None:
self._io_pool = ThreadPoolExecutor(
max_workers=2, thread_name_prefix="fake-ctrl"
)
self._signal_handler_error = None
def _shutdown_io_pool_result(self):
"""Phase 6 Group 6.1 helper (Result-based)."""
from src.result_types import OK, ErrorInfo, ErrorKind, Result
try:
self._io_pool.shutdown(wait=False)
return OK
except Exception as e:
return Result(data=None, errors=[ErrorInfo(
kind=ErrorKind.INTERNAL, message=str(e),
source="app_controller._shutdown_io_pool_result", original=e,
)])
def _install_signal_handler_result(self, handler):
"""Phase 6 Group 6.1 helper (Result-based)."""
from src.result_types import OK, ErrorInfo, ErrorKind, Result
try:
signal.signal(signal.SIGINT, handler)
return OK
except Exception as e:
return Result(data=None, errors=[ErrorInfo(
kind=ErrorKind.INTERNAL, message=str(e),
source="app_controller._install_signal_handler_result", original=e,
)])
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)"