fix(sigint): install SIGINT handler in AppController to drain pool on Ctrl+C
Ctrl+C in sloppy.py's terminal would hang the process when a worker of the shared 4-thread I/O pool was mid-task in user code (e.g. a long- running Gemini/Anthropic HTTP request). The hang chain: 1. SIGINT delivered to main thread 2. Python raises KeyboardInterrupt (default handler) 3. Exception propagates out of main() 4. Interpreter finalization begins 5. ThreadPoolExecutor.__del__ runs shutdown(wait=True) 6. shutdown(wait=True) joins all worker threads 7. The blocked worker never returns -> hang An atexit-based fix (mirroring the conftest fix at8957c9a5) was attempted first: register pool.shutdown(wait=False) at pool creation. Verified empirically that this DOES NOT WORK — atexit handlers do not fire at all when a pool worker is blocked in user code. The hang still occurs in ThreadPoolExecutor.__del__ -> shutdown(wait=True). Production fix: a SIGINT handler installed by AppController.__init__ that drains the pool non-blockingly and calls os._exit(0), bypassing the broken finalization chain. One wire covers all three modes (GUI/headless/web) since they all create an AppController. Files: - src/app_controller.py: new module-level _install_sigint_exit_handler helper called from __init__; one-line docstring at the function level documents the rationale. - tests/test_app_controller_sigint.py: new test file with 2 regression tests (unit: handler is installed on main thread; subprocess: handler exits within 2s when invoked with a blocked worker). - tests/test_io_pool.py: module docstring updated to explain the reverted atexit approach and point readers at the production fix. Best-effort: signal.signal may fail on non-main threads (some conftest warmup paths); failure is swallowed. The conftest's own atexit fix at8957c9a5covers the test fixture's normal-exit path.
This commit is contained in:
@@ -5,6 +5,7 @@ import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -794,6 +795,39 @@ def _handle_hide_patch_modal(controller: 'AppController', task: dict):
|
||||
|
||||
#endregion
|
||||
|
||||
def _install_sigint_exit_handler(controller: 'AppController') -> None:
|
||||
"""
|
||||
|
||||
Install a SIGINT handler that drains the controller's I/O pool
|
||||
(wait=False) and calls ``os._exit(0)``. This sidesteps the broken
|
||||
Python interpreter finalization chain that hangs the process when
|
||||
Ctrl+C is pressed while a worker is mid-task in user code
|
||||
(e.g. a long-running Gemini/Anthropic HTTP request).
|
||||
Background: ``ThreadPoolExecutor.__del__`` -> ``shutdown(wait=True)``
|
||||
joins all worker threads; atexit handlers do not fire reliably in
|
||||
that scenario, so the interpreter never reaches the pool-shutdown
|
||||
path. Bypassing finalization with ``os._exit(0)`` is the only
|
||||
reliable fix.
|
||||
[SDM: src/app_controller.py:_install_sigint_exit_handler]
|
||||
Best-effort: ``signal.signal`` may fail with ``ValueError`` on
|
||||
non-main threads (e.g. some conftest warmup paths). The failure
|
||||
is swallowed because production (main thread) is the only case
|
||||
that matters; the conftest's own atexit fix at commit 8957c9a5
|
||||
covers the test fixture's normal-exit path.
|
||||
[C: src/app_controller.py:AppController.__init__]
|
||||
"""
|
||||
def _on_sigint(signum: int, frame: Any) -> None:
|
||||
try:
|
||||
controller._io_pool.shutdown(wait=False)
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
try:
|
||||
signal.signal(signal.SIGINT, _on_sigint)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
class AppController:
|
||||
"""
|
||||
|
||||
@@ -830,6 +864,7 @@ class AppController:
|
||||
|
||||
# --- Shared background pool + proactive warmup (startup_speedup_20260606) ---
|
||||
self._io_pool = make_io_pool()
|
||||
_install_sigint_exit_handler(self)
|
||||
# Warmup progress is a diagnostic; keep stderr quiet unless explicitly asked.
|
||||
# Explicit log_to_stderr arg wins; otherwise default to the SLOP_WARMUP_DEBUG env flag.
|
||||
if log_to_stderr is None:
|
||||
|
||||
Reference in New Issue
Block a user