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 hashlib
import logging
import socket
import threading
from collections import defaultdict
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.
try:
import vk_api
@@ -52,6 +62,10 @@ BAN_SEC = 600
ADMINS_CACHE_TTL = 30
# Как часто фоновый поток проверяет, не пора ли отправить напоминание
REMINDER_CHECK_INTERVAL_SEC = 60
# Сколько секунд основной цикл LongPoll может не подавать признаков жизни,
# прежде чем watchdog сочтёт его зависшим и принудительно завершит процесс
# (штатный опрос происходит раз в ~25-35 с, так что 5 минут — большой запас)
WATCHDOG_TIMEOUT_SEC = 300
REMINDER_PERIOD_LABELS = {
"once": "разово",
@@ -2131,9 +2145,28 @@ class VKBotApplication:
logger.error(f"Ошибка обработки напоминаний: {e}")
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):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
self._last_alive_ts = time.time()
threading.Thread(target=self._reminders_loop, daemon=True).start()
threading.Thread(target=self._watchdog_loop, daemon=True).start()
if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
@@ -2163,9 +2196,16 @@ class VKBotApplication:
logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
# Вызываем check() вручную (а не for event in self.longpoll.listen()), чтобы
# обновлять "пульс" для watchdog на каждом опросе сервера (раз в ~25-35 с),
# а не только когда реально приходит сообщение — иначе в тихие часы без
# сообщений watchdog решит, что бот завис, и перезапустит его зря.
while True:
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:
message_obj = event.obj.message
user_id = message_obj["from_id"]