Files
invoke-ai--invokeai/tests/test_asyncio_shutdown.py
wehub-resource-sync cddb07a176
build container image / cpu (push) Waiting to run
build container image / cuda (push) Waiting to run
build container image / rocm (push) Waiting to run
frontend tests / frontend-tests (push) Waiting to run
openapi checks / openapi-checks (push) Waiting to run
python tests / py3.12: macos-default (push) Waiting to run
python tests / py3.11: windows-cpu (push) Waiting to run
python tests / py3.12: windows-cpu (push) Waiting to run
python tests / py3.11: linux-cpu (push) Waiting to run
python tests / py3.12: linux-cpu (push) Waiting to run
typegen checks / typegen-checks (push) Waiting to run
uv lock checks / uv-lock-checks (push) Waiting to run
frontend checks / frontend-checks (push) Waiting to run
lfs checks / lfs-check (push) Waiting to run
python checks / python-checks (push) Waiting to run
python tests / py3.11: macos-default (push) Waiting to run
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08: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