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

View File

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