Merge: защита от тихого зависания бота (watchdog + сетевой таймаут)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kenik
2026-09-06 13:48:37 +03:00
+41 -1
View File
@@ -10,10 +10,20 @@ import time
import hmac import hmac
import hashlib import hashlib
import logging import logging
import socket
import threading import threading
from collections import defaultdict from collections import defaultdict
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
# Глобальный таймаут для всех сетевых сокетов без явного timeout.
# Без этого библиотека vk_api ставит таймаут только на сам LongPoll-запрос
# (wait+10 сек), а обычные вызовы API (messages.send и т.п.) идут без
# таймаута вообще — если VK/сеть один раз не ответит, соединение висит
# бесконечно и весь однопоточный цикл обработки сообщений намертво стопорится
# без единой строки в логе. Docker restart:unless-stopped при этом не
# помогает — процесс не падает, а просто зависает.
socket.setdefaulttimeout(60)
# Нам понадобятся библиотеки vk_api. # Нам понадобятся библиотеки vk_api.
try: try:
import vk_api import vk_api
@@ -52,6 +62,10 @@ BAN_SEC = 600
ADMINS_CACHE_TTL = 30 ADMINS_CACHE_TTL = 30
# Как часто фоновый поток проверяет, не пора ли отправить напоминание # Как часто фоновый поток проверяет, не пора ли отправить напоминание
REMINDER_CHECK_INTERVAL_SEC = 60 REMINDER_CHECK_INTERVAL_SEC = 60
# Сколько секунд основной цикл LongPoll может не подавать признаков жизни,
# прежде чем watchdog сочтёт его зависшим и принудительно завершит процесс
# (штатный опрос происходит раз в ~25-35 с, так что 5 минут — большой запас)
WATCHDOG_TIMEOUT_SEC = 300
REMINDER_PERIOD_LABELS = { REMINDER_PERIOD_LABELS = {
"once": "разово", "once": "разово",
@@ -2131,9 +2145,28 @@ class VKBotApplication:
logger.error(f"Ошибка обработки напоминаний: {e}") logger.error(f"Ошибка обработки напоминаний: {e}")
time.sleep(REMINDER_CHECK_INTERVAL_SEC) time.sleep(REMINDER_CHECK_INTERVAL_SEC)
def _watchdog_loop(self):
"""Фоновый страж: если основной цикл LongPoll не подавал признаков
жизни дольше WATCHDOG_TIMEOUT_SEC (завис на каком-то вызове без
таймаута, дедлок и т.п.), принудительно завершает процесс — чтобы
Docker (restart: unless-stopped) реально перезапустил контейнер.
Без этого зависший, но не упавший процесс никто не перезапустит."""
while True:
time.sleep(30)
stale_for = time.time() - self._last_alive_ts
if stale_for > WATCHDOG_TIMEOUT_SEC:
logger.critical(
f"Watchdog: основной цикл не подавал признаков жизни {stale_for:.0f} с "
f"(порог {WATCHDOG_TIMEOUT_SEC} с). Похоже на зависание — принудительный выход "
f"для перезапуска контейнером."
)
os._exit(1)
def run(self): def run(self):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll.""" """Запуск бесконечного цикла прослушивания событий VK LongPoll."""
self._last_alive_ts = time.time()
threading.Thread(target=self._reminders_loop, daemon=True).start() threading.Thread(target=self._reminders_loop, daemon=True).start()
threading.Thread(target=self._watchdog_loop, daemon=True).start()
if not self.longpoll: if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).") logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.") print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
@@ -2163,9 +2196,16 @@ class VKBotApplication:
logger.error(f"Не удалось отправить старт-пинг: {e}") logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота. # Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
# Вызываем check() вручную (а не for event in self.longpoll.listen()), чтобы
# обновлять "пульс" для watchdog на каждом опросе сервера (раз в ~25-35 с),
# а не только когда реально приходит сообщение — иначе в тихие часы без
# сообщений watchdog решит, что бот завис, и перезапустит его зря.
while True: while True:
try: try:
for event in self.longpoll.listen(): self._last_alive_ts = time.time()
events = self.longpoll.check()
self._last_alive_ts = time.time()
for event in events:
if event.type == VkBotEventType.MESSAGE_NEW: if event.type == VkBotEventType.MESSAGE_NEW:
message_obj = event.obj.message message_obj = event.obj.message
user_id = message_obj["from_id"] user_id = message_obj["from_id"]