Files
InvokeAI/tests/test_asyncio_shutdown.py
Lincoln Stein c7bdaf93b2 Fix: Shut down the server with one keyboard interrupt (#94) (#8936)
* Fix: Kill the server with one keyboard interrupt (#94)

* Initial plan

* Handle KeyboardInterrupt in run_app to allow single Ctrl+C shutdown

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

* Force os._exit(0) on KeyboardInterrupt to avoid hanging on background threads

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

Fix graceful shutdown to wait for download/install worker threads (#102)

* Initial plan

* Replace os._exit(0) with ApiDependencies.shutdown() on KeyboardInterrupt

Instead of immediately force-exiting the process on CTRL+C, call
ApiDependencies.shutdown() to gracefully stop the download and install
manager services, allowing active work to complete or cancel cleanly
before the process exits.

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

* Make stop() idempotent in download and model install services

When CTRL+C is pressed, uvicorn's graceful shutdown triggers the FastAPI
lifespan which calls ApiDependencies.shutdown(), then a KeyboardInterrupt
propagates from run_until_complete() hitting the except block which tries
to call ApiDependencies.shutdown() a second time.

Change both stop() methods to return silently (instead of raising) when
the service is not running. This handles:
- Double-shutdown: lifespan already stopped the services
- Early interrupt: services were never fully started

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

Fix shutdown hang on session processor thread lock (#108)

* Initial plan

* Fix shutdown hang: wake session processor thread on stop() and mark daemon

Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

* Fix: shut down asyncio executor on KeyboardInterrupt to prevent post-generation hang (#112)

Fix: cancel pending asyncio tasks before loop.close() to suppress destroyed-task warnings
Fix: suppress stack trace when dispatching events after event loop is closed on shutdown
Fix: cancel in-progress generation on stop() to prevent core dump during mid-flight Ctrl+C

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
2026-03-05 22:01:40 -05:00

148 lines
5.8 KiB
Python

"""
Tests that verify the fix for the two-Ctrl+C shutdown hang.
Root cause: asyncio.to_thread() (used during generation for SQLite session queue operations)
creates non-daemon threads via the event loop's default ThreadPoolExecutor. When the event
loop is interrupted by KeyboardInterrupt without calling loop.shutdown_default_executor() and
loop.close(), those non-daemon threads remain alive and cause threading._shutdown() to block.
The fix in run_app.py:
1. Cancels all pending asyncio tasks (e.g. socket.io ping tasks) to avoid "Task was destroyed
but it is pending!" warnings when loop.close() is called.
2. Calls loop.run_until_complete(loop.shutdown_default_executor()) followed by loop.close()
after ApiDependencies.shutdown(), so all executor threads are cleaned up before the process
begins its Python-level teardown.
"""
from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess
def test_asyncio_to_thread_creates_nondaemon_thread():
"""Confirm that asyncio.to_thread() leaves a non-daemon thread alive after run_until_complete()
is interrupted - this is the raw symptom that caused the two-Ctrl+C hang."""
def test_func():
import asyncio
import threading
async def use_thread():
await asyncio.to_thread(lambda: None)
loop = asyncio.new_event_loop()
loop.run_until_complete(use_thread())
# Deliberately do NOT call shutdown_default_executor() or loop.close()
non_daemon = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()]
# There should be at least one non-daemon executor thread still alive
if not non_daemon:
raise AssertionError("Expected a non-daemon thread but found none")
print("ok")
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0, _stderr
assert stdout.strip() == "ok"
def test_shutdown_default_executor_cleans_up_nondaemon_threads():
"""Verify that calling shutdown_default_executor() + loop.close() eliminates all non-daemon
threads created by asyncio.to_thread() - this is the fix applied in run_app.py."""
def test_func():
import asyncio
import threading
async def use_thread():
await asyncio.to_thread(lambda: None)
loop = asyncio.new_event_loop()
loop.run_until_complete(use_thread())
# Apply the fix
loop.run_until_complete(loop.shutdown_default_executor())
loop.close()
non_daemon = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()]
if non_daemon:
raise AssertionError(f"Expected no non-daemon threads but found: {[t.name for t in non_daemon]}")
print("ok")
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0, _stderr
assert stdout.strip() == "ok"
def test_shutdown_default_executor_works_after_simulated_keyboard_interrupt():
"""Verify that the fix works even when run_until_complete() was previously interrupted,
matching the exact flow in run_app.py's except KeyboardInterrupt block."""
def test_func():
import asyncio
import threading
async def use_thread_then_raise():
await asyncio.to_thread(lambda: None)
raise KeyboardInterrupt
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(use_thread_then_raise())
except KeyboardInterrupt:
pass
# At this point a non-daemon thread exists (the bug)
non_daemon_before = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()]
if not non_daemon_before:
raise AssertionError("Expected a non-daemon thread before fix")
# Apply the fix (what run_app.py now does)
loop.run_until_complete(loop.shutdown_default_executor())
loop.close()
non_daemon_after = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()]
if non_daemon_after:
raise AssertionError(f"Non-daemon threads remain after fix: {[t.name for t in non_daemon_after]}")
print("ok")
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0, _stderr
assert stdout.strip() == "ok"
def test_cancel_pending_tasks_suppresses_destroyed_task_warnings():
"""Verify that cancelling pending tasks before loop.close() suppresses 'Task was destroyed
but it is pending!' warnings (e.g. from socket.io ping tasks)."""
def test_func():
import asyncio
async def long_running():
await asyncio.sleep(1) # simulates a socket.io ping task
async def start_background_task():
asyncio.create_task(long_running())
await asyncio.to_thread(lambda: None)
raise KeyboardInterrupt
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(start_background_task())
except KeyboardInterrupt:
pass
# Apply the task-cancellation fix
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.run_until_complete(loop.shutdown_default_executor())
loop.close()
print("ok")
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
assert returncode == 0, _stderr
assert stdout.strip() == "ok"
# The "Task was destroyed but it is pending!" message appears on stderr when tasks are NOT
# cancelled before loop.close(). After the fix it must be absent.
assert "Task was destroyed but it is pending" not in _stderr