Private
Public Access
0
0

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 at 8957c9a5) 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 at
8957c9a5 covers the test fixture's normal-exit path.
This commit is contained in:
2026-06-07 02:00:56 -04:00
parent aa70653065
commit abc333f91b
3 changed files with 178 additions and 1 deletions
+35
View File
@@ -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: