Исправление: замена signal handler на on_shutdown (устранён race condition event loop)

This commit is contained in:
deadzilla 2026-07-07 22:51:23 +05:00
parent 07af928654
commit 5a11fb2b5d
2 changed files with 27 additions and 47 deletions

40
bot.py
View File

@ -1,7 +1,6 @@
import asyncio import asyncio
import logging import logging
import os import os
import signal
import sys import sys
import threading import threading
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -106,31 +105,15 @@ class BotRunner:
"""Повторяет текст после !msg""" """Повторяет текст после !msg"""
await ctx.send(text) await ctx.send(text)
def _signal_handler(self, signum: int, frame) -> None: def _on_shutdown(self) -> None:
"""Обработать сигнал завершения для graceful shutdown. """Остановить планировщик при завершении работы бота."""
Останавливает планировщик и корректно закрывает бота,
чтобы подключения к gateway завершались штатно.
"""
sig_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
logger.info("Получен сигнал %s, graceful shutdown...", sig_name)
self.stop_event.set()
# Остановить планировщик
if self.scheduler: if self.scheduler:
self.scheduler.stop() self.scheduler.stop()
logger.info("Планировщик остановлен")
# Закрыть бота асинхронно async def _on_shutdown_async(self) -> None:
loop = asyncio.new_event_loop() """Асинхронный хук завершения (discord.py on_shutdown)."""
asyncio.set_event_loop(loop) self._on_shutdown()
try:
loop.run_until_complete(self.bot.close())
except Exception:
pass
finally:
loop.close()
sys.exit(0)
def run(self, token: str) -> None: def run(self, token: str) -> None:
"""Запустить бота с graceful shutdown.""" """Запустить бота с graceful shutdown."""
@ -139,6 +122,8 @@ class BotRunner:
async def main() -> None: async def main() -> None:
try: try:
async with self.bot: async with self.bot:
# Регистрируем on_shutdown внутри контекста бота
self.bot.add_listener(self._on_shutdown_async, "on_shutdown")
await self.bot.start(token, reconnect=True) await self.bot.start(token, reconnect=True)
except discord.LoginFailure as e: except discord.LoginFailure as e:
logger.critical("Ошибка авторизации бота: %s", e, exc_info=True) logger.critical("Ошибка авторизации бота: %s", e, exc_info=True)
@ -158,15 +143,14 @@ class BotRunner:
) )
logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__) logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__)
sys.exit(1) sys.exit(1)
finally:
signal.signal(signal.SIGINT, self._signal_handler) # Context manager (async with self.bot) закрывает бота автоматически
signal.signal(signal.SIGTERM, self._signal_handler) self._on_shutdown()
try: try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
# asyncio.run() выбрасывает KeyboardInterrupt при Ctrl+C, # Ctrl+C прерывает bot.start(), context manager закрывает бота
# но сигнал уже обработан _signal_handler и бот закрыт
logger.info("Bot shutdown complete") logger.info("Bot shutdown complete")
sys.exit(0) sys.exit(0)

View File

@ -62,28 +62,24 @@ class TestBotErrorHandling:
runner.run("fake_token") runner.run("fake_token")
mock_exit.assert_called_once_with(1) mock_exit.assert_called_once_with(1)
def test_signal_handlers_installed(self): def test_shutdown_uses_on_shutdown_listener(self):
"""BotRunner.run() устанавливает обработчики SIGINT и SIGTERM.""" """BotRunner.run() регистрирует on_shutdown вместо signal handlers.
import asyncio
Signal handlers с asyncio.new_event_loop() создают race condition
с основным loop. Вместо них используется:
- discord.py on_shutdown событие для остановки планировщика
- async with self.bot (context manager) для graceful shutdown
"""
import bot import bot
import signal
runner = bot.BotRunner() runner = bot.BotRunner()
# Проверяем, что _on_shutdown и _on_shutdown_async методы существуют
def fake_run(coro): assert hasattr(runner, "_on_shutdown"), "Метод _on_shutdown должен существовать"
"""Поддельный asyncio.run, который утилизирует корутину.""" assert hasattr(runner, "_on_shutdown_async"), "Метод _on_shutdown_async должен существовать"
try: # Проверяем, что signal модуль НЕ импортирован в bot.py
coro.close() with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
except RuntimeError: content = f.read()
pass # корутина уже закрыта assert "signal.signal" not in content, "Не должно быть signal.signal — используется on_shutdown"
with patch("signal.signal") as mock_signal:
with patch("asyncio.run", side_effect=fake_run):
runner.run("fake_token")
# signal.signal вызван для SIGINT и SIGTERM
call_args = [call[0][0] for call in mock_signal.call_args_list]
assert signal.SIGINT in call_args
assert signal.SIGTERM in call_args
def test_code_uses_async_bot_pattern(self): def test_code_uses_async_bot_pattern(self):
"""Проверка, что bot.py использует async with / asyncio.run.""" """Проверка, что bot.py использует async with / asyncio.run."""