Recover stalled database connections and skip unreachable chat recipients
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-13 20:21:20 +09:00
parent 8094cbb8f7
commit d5a9c13b68
10 changed files with 122 additions and 14 deletions

View File

@@ -14,6 +14,8 @@ DATABASE_URL=postgresql+asyncpg://lottery_user:your_strong_password_here@postgre
COMPOSE_PROFILES=local-db COMPOSE_PROFILES=local-db
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
FSM_TTL_SECONDS=86400 FSM_TTL_SECONDS=86400
# Short connection lifetime verified against the production network.
DB_POOL_RECYCLE=15
# ID администраторов (через запятую) # ID администраторов (через запятую)
ADMIN_IDS=123456789,987654321 ADMIN_IDS=123456789,987654321

View File

@@ -52,10 +52,10 @@ Drone: `https://drone.smartsoltech.kr`, цель: `trevor@192.168.40.112:/opt/ne
- Создаёт каталог `releases/COMMIT-BUILD`, собирает образ с тегом commit и поднимает Redis/необходимую локальную БД. - Создаёт каталог `releases/COMMIT-BUILD`, собирает образ с тегом commit и поднимает Redis/необходимую локальную БД.
- Делает PostgreSQL backup в `backups/COMMIT-BUILD-UTC_TIMESTAMP.dump` с ограниченными правами. Дальнейшие действия прекращаются, если backup не получен. - Делает PostgreSQL backup в `backups/COMMIT-BUILD-UTC_TIMESTAMP.dump` с ограниченными правами. Дальнейшие действия прекращаются, если backup не получен.
- Останавливает старого polling-бота, выполняет `alembic upgrade head` и проверку схемы. - Останавливает старого polling-бота, выполняет `alembic upgrade head` и проверку схемы.
- Запускает новый образ, ожидает healthcheck, проверяет heartbeat приложения и только затем переключает `current` и записывает `last-build`. - Запускает новый образ, ожидает healthcheck, затем ещё 120 секунд проверяет heartbeat и отсутствие перезапусков. Только после этого переключает `current` и записывает `last-build`.
- При ошибке после остановки пытается вернуть прежний образ. **Это откат приложения:** схема БД автоматически не откатывается; используется env текущего deployment. Восстановление БД из backup требует отдельного решения. При первом запуске предыдущего образа может не быть. - При ошибке после остановки пытается вернуть прежний образ. **Это откат приложения:** схема БД автоматически не откатывается; используется env текущего deployment. Восстановление БД из backup требует отдельного решения. При первом запуске предыдущего образа может не быть.
Heartbeat обновляется после проверки БД в работающем event loop; до запуска бот проверяет Redis, схему и Telegram `getMe`. Сбой heartbeat останавливает приложение, после чего действует Docker restart policy. Один только Docker healthcheck не является проверкой каждого бизнес-сценария или факта доставки всех сообщений. Heartbeat обновляется после проверки БД в работающем event loop; до запуска бот проверяет Redis, схему и Telegram `getMe`. Отдельный watchdog-поток завершает процесс при устаревшем heartbeat, даже если завис драйвер или event loop; после этого действует Docker restart policy. На рабочем сетевом маршруте длительное повторное использование соединений зависало: в `lottery_env` установлен `DB_POOL_RECYCLE=15`, сохраняющий ограниченный пул с коротким сроком жизни соединения. Один только начальный Docker healthcheck не является проверкой длительной работы или доставки всех сообщений.
## Проверка результата ## Проверка результата

View File

@@ -31,6 +31,8 @@
| Средний | Excel мог содержать формулы, чрезмерно большой архив или строку, срывающую весь импорт | Экспорт строк как текста; ограничения размера/числа строк; безопасный XML; отдельная транзакция для каждой строки; импорт не назначает административные роли | | Средний | Excel мог содержать формулы, чрезмерно большой архив или строку, срывающую весь импорт | Экспорт строк как текста; ограничения размера/числа строк; безопасный XML; отдельная транзакция для каждой строки; импорт не назначает административные роли |
| Средний | HTML в имени/тексте ломал пересылку, длинный текст переставал помещаться с заголовком | Экранирование и сохранение Telegram entities; для предельного размера заголовок отправляется отдельно | | Средний | HTML в имени/тексте ломал пересылку, длинный текст переставал помещаться с заголовком | Экранирование и сохранение Telegram entities; для предельного размера заголовок отправляется отдельно |
| Средний | Разные интерфейсы бана/разбана расходились; повторная запись о блокировке ломала рассылку и задачу неактивности | Оба механизма блокировки согласованы; служебные записи обновляются атомарно | | Средний | Разные интерфейсы бана/разбана расходились; повторная запись о блокировке ломала рассылку и задачу неактивности | Оба механизма блокировки согласованы; служебные записи обновляются атомарно |
| Средний | Чат повторно отправлял сообщения недоступным адресатам, включая собственную старую запись бота | Собственный ID исключён; отказ Telegram помечает недоступного получателя для пропуска последующих отправок. Входящее событие пользователя восстанавливает доставку; ошибка содержимого не блокирует получателя |
| Высокий | При длительном повторном использовании соединения с рабочей БД зависала проверка здоровья; Docker сам не перезапускает unhealthy-контейнер | Настроен короткий срок жизни соединений в рабочем пуле. Отдельный watchdog завершает процесс при остановке heartbeat, включая зависание event loop или отмены запроса драйвера |
| Средний | Ошибки SQL/исключений уходили пользователю; были обращения к удалённому `User.account_number` и несуществующим методам | Общие сообщения об ошибке без технических деталей, исправленные обращения к Account и сервисам, критическая статическая проверка в CI | | Средний | Ошибки SQL/исключений уходили пользователю; были обращения к удалённому `User.account_number` и несуществующим методам | Общие сообщения об ошибке без технических деталей, исправленные обращения к Account и сервисам, критическая статическая проверка в CI |
| Средний | CI скрывал ошибки, а наличие процесса считалось успешным деплоем | Проверки останавливают pipeline при ошибке; проверяются миграции, схема, доступ к БД, запуск Telegram и heartbeat приложения | | Средний | CI скрывал ошибки, а наличие процесса считалось успешным деплоем | Проверки останавливают pipeline при ошибке; проверяются миграции, схема, доступ к БД, запуск Telegram и heartbeat приложения |
| Средний | Установленные зависимости содержали известные уязвимости; старый aioredis несовместим с Python 3.12 | Обновлены aiogram/aiohttp/python-dotenv, удалён неиспользуемый aioredis; выполнен pip-audit | | Средний | Установленные зависимости содержали известные уязвимости; старый aioredis несовместим с Python 3.12 | Обновлены aiogram/aiohttp/python-dotenv, удалён неиспользуемый aioredis; выполнен pip-audit |
@@ -51,7 +53,7 @@
CI запускает тесты на SQLite и отдельной PostgreSQL 16, соответствующей рабочему серверу. Проверка Alembic создаёт дополнительную временную БД, выполняет полную миграцию и обновление с рабочей ревизии, повторяет upgrade head и проверяет наличие ожидаемых таблиц/полей. SQLite используется для тестов сервисов через SQLAlchemy metadata; историческая цепочка миграций предназначена для PostgreSQL. CI запускает тесты на SQLite и отдельной PostgreSQL 16, соответствующей рабочему серверу. Проверка Alembic создаёт дополнительную временную БД, выполняет полную миграцию и обновление с рабочей ревизии, повторяет upgrade head и проверяет наличие ожидаемых таблиц/полей. SQLite используется для тестов сервисов через SQLAlchemy metadata; историческая цепочка миграций предназначена для PostgreSQL.
Итоговый локальный прогон с поддержкой премиум-эмодзи: **62 passed на PostgreSQL 15**; **60 passed, 2 skipped на SQLite** (пропущены PostgreSQL-тесты миграций). Оба прогона выдают по шесть предупреждений openpyxl об устаревающем `datetime.utcnow`; падений тестов нет. Поддержка эмодзи описана в [EMOJI_SYSTEM.md](EMOJI_SYSTEM.md). Основной прогон с поддержкой премиум-эмодзи: **62 passed на PostgreSQL 15 локально и PostgreSQL 16 в Drone**. После добавления проверок watchdog и недоступных получателей локально: **65 passed, 2 skipped на SQLite** (пропущены PostgreSQL-тесты миграций). Прогоны выдают по шесть предупреждений openpyxl об устаревающем `datetime.utcnow`; падений тестов нет. Поддержка эмодзи описана в [EMOJI_SYSTEM.md](EMOJI_SYSTEM.md).
Дополнительно прошли compileall, Ruff для критических ошибок, проверка состава релизного архива, поиск секретов в отслеживаемых файлах и синтаксическая проверка shell-скрипта. `pip-audit -r requirements.txt` сообщил `No known vulnerabilities found`. Это результат проверки известных уязвимостей зависимостей на дату аудита, а не гарантия безопасности всего приложения. Дополнительно прошли compileall, Ruff для критических ошибок, проверка состава релизного архива, поиск секретов в отслеживаемых файлах и синтаксическая проверка shell-скрипта. `pip-audit -r requirements.txt` сообщил `No known vulnerabilities found`. Это результат проверки известных уязвимостей зависимостей на дату аудита, а не гарантия безопасности всего приложения.
@@ -59,7 +61,7 @@ CI запускает тесты на SQLite и отдельной PostgreSQL 16
1. **Drone и сервер.** Проверены SSH-доступ, права Docker, активность репозитория `trevor/new_lottery_bot` в `https://drone.smartsoltech.kr`. Настроены пять deployment-секретов. Успех удалённого deployment подтверждается отдельно после push; локальный зелёный прогон его не заменяет. 1. **Drone и сервер.** Проверены SSH-доступ, права Docker, активность репозитория `trevor/new_lottery_bot` в `https://drone.smartsoltech.kr`. Настроены пять deployment-секретов. Успех удалённого deployment подтверждается отдельно после push; локальный зелёный прогон его не заменяет.
2. **Рабочая инфраструктура.** Docker-образ собран на целевом хосте. Эффективные настройки и volumes сопоставлены с действующим контейнером; PostgreSQL 16.15 на `192.168.20.2` доступен. Реальная проверка восстановления выявила несовместимость клиента pg_dump 17 с версией рабочей БД 16; образ закрепляет клиент 16 из официального PostgreSQL APT-репозитория. После исправления production backup успешно восстановлен в изолированном PostgreSQL 16, применена новая миграция и проверена схема; рабочая БД этой проверкой не изменялась. Автоматический откат приложения при искусственном сбое пока не проверялся. 2. **Рабочая инфраструктура.** Docker-образ собран на целевом хосте. Эффективные настройки и volumes сопоставлены с действующим контейнером; PostgreSQL 16.15 на `192.168.20.2` доступен. Реальная проверка восстановления выявила несовместимость клиента pg_dump 17 с версией рабочей БД 16; образ закрепляет клиент 16 из официального PostgreSQL APT-репозитория. После исправления production backup успешно восстановлен в изолированном PostgreSQL 16, применена новая миграция и проверена схема; рабочая БД этой проверкой не изменялась. Автоматический откат приложения при искусственном сбое пока не проверялся.
3. **История секретов.** Удаление файла из будущего коммита не отзывает ранее записанные токен бота и пароль БД. Их нужно сменить и передать новые значения в Drone. История Git не переписывалась. 3. **История секретов.** Runtime-файл удалён из текущей версии Git. Действующий токен бота отличается от удалённого файла, но пароль рабочей БД совпадает с ранее опубликованным. Пароль БД ещё требует согласованной замены во всех потребителях; история Git не переписывалась.
4. **Подтверждение клубной карты.** Текущая бизнес-логика принимает введённый пользователем номер карты без проверки по внешней системе или отдельного подтверждения кассиром. Она не доказывает, что карта принадлежит заявителю. Для устранения этого риска требуется определить источник проверки владельца; автоматического подтверждения такой принадлежности сейчас нет. 4. **Подтверждение клубной карты.** Текущая бизнес-логика принимает введённый пользователем номер карты без проверки по внешней системе или отдельного подтверждения кассиром. Она не доказывает, что карта принадлежит заявителю. Для устранения этого риска требуется определить источник проверки владельца; автоматического подтверждения такой принадлежности сейчас нет.
5. **Нагрузка и внешние сбои.** Тесты используют подменённые ответы Telegram и отдельные тестовые БД. Длительная нагрузка с реальными пользователями и потеря сети/Redis ещё не проверены. Рассылки не имеют долговременной очереди восстановления после рестарта; незавершённую рассылку может потребоваться повторить. 5. **Нагрузка и внешние сбои.** Тесты используют подменённые ответы Telegram и отдельные тестовые БД. Длительная нагрузка с реальными пользователями и потеря сети/Redis ещё не проверены. Рассылки не имеют долговременной очереди восстановления после рестарта; незавершённую рассылку может потребоваться повторить.
6. **Производственные данные.** Проверка перед выпуском не обнаружила дубликатов участников или призовых мест. Новая миграция повторяет эту проверку и останавливается при неоднозначных данных; она не удаляет их автоматически. 6. **Производственные данные.** Проверка перед выпуском не обнаружила дубликатов участников или призовых мест. Новая миграция повторяет эту проверку и останавливается при неоднозначных данных; она не удаляет их автоматически.

10
main.py
View File

@@ -336,8 +336,9 @@ async def main():
# Запускаем polling # Запускаем polling
try: try:
from src.core.health import HEARTBEAT, heartbeat from src.core.health import HEARTBEAT, heartbeat, start_watchdog
HEARTBEAT.unlink(missing_ok=True) HEARTBEAT.unlink(missing_ok=True)
watchdog = start_watchdog()
if REDIS_URL: if REDIS_URL:
await storage.redis.ping() await storage.redis.ping()
from sqlalchemy import select from sqlalchemy import select
@@ -345,12 +346,15 @@ async def main():
async with async_session_maker() as session: async with async_session_maker() as session:
await session.execute(select(User).limit(0)) await session.execute(select(User).limit(0))
await bot.get_me() await bot.get_me()
health_task = asyncio.create_task(heartbeat()) health_task = asyncio.create_task(heartbeat(), name="database-heartbeat")
logger.info("Бот запущен") logger.info("Бот запущен")
polling_task = asyncio.create_task(dp.start_polling(bot)) polling_task = asyncio.create_task(dp.start_polling(bot))
completed, _ = await asyncio.wait({health_task, polling_task}, return_when=asyncio.FIRST_COMPLETED) completed, _ = await asyncio.wait({health_task, polling_task}, return_when=asyncio.FIRST_COMPLETED)
for task in completed: for task in completed:
await task await task
except Exception:
logger.exception("Runtime supervision failed; stopping the application")
raise
finally: finally:
from src.core.database import close_db from src.core.database import close_db
from contextlib import suppress from contextlib import suppress
@@ -366,6 +370,8 @@ async def main():
await bot.session.close() await bot.session.close()
await storage.close() await storage.close()
await close_db() await close_db()
if "watchdog" in locals():
watchdog.set()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -54,6 +54,12 @@ compose run --rm --no-deps bot python -m alembic upgrade head
compose run --rm --no-deps bot python scripts/check_schema.py compose run --rm --no-deps bot python scripts/check_schema.py
compose up -d --no-build --wait --wait-timeout 180 bot compose up -d --no-build --wait --wait-timeout 180 bot
docker exec lottery_bot python -m src.core.health docker exec lottery_bot python -m src.core.health
echo "Verifying application health and restart count for 120 seconds"
for attempt in 1 2 3 4 5 6 7 8; do
sleep 15
docker exec lottery_bot python -m src.core.health
test "$(docker inspect --format '{{.RestartCount}}' lottery_bot)" -eq 0
done
ln -s "$release" "$root/current.$build" ln -s "$release" "$root/current.$build"
mv -Tf "$root/current.$build" "$root/current" mv -Tf "$root/current.$build" "$root/current"
printf '%s\n' "$build" > "$root/last-build" printf '%s\n' "$build" > "$root/last-build"

View File

@@ -133,7 +133,7 @@ class ActivityService:
.where( .where(
and_( and_(
BlockedUser.telegram_id == telegram_id, BlockedUser.telegram_id == telegram_id,
BlockedUser.error_type == 'inactive', BlockedUser.error_type.in_(['inactive', 'blocked_bot', 'deactivated', 'not_found', 'chat_not_found']),
BlockedUser.is_active == True BlockedUser.is_active == True
) )
) )

View File

@@ -1,6 +1,8 @@
"""Health reflects a live event loop and a usable database connection.""" """Health reflects a live event loop and a usable database connection."""
import asyncio import asyncio
import logging
import os import os
import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -13,9 +15,12 @@ HEARTBEAT = Path(os.getenv("HEARTBEAT_FILE", "/tmp/lottery-heartbeat"))
async def heartbeat(): async def heartbeat():
while True: while True:
async with async_session_maker() as session: async with asyncio.timeout(20):
await session.execute(text("SELECT 1")) async with async_session_maker() as session:
HEARTBEAT.write_text(str(time.time()), encoding="ascii") await session.execute(text("SELECT 1"))
pending = HEARTBEAT.with_name(HEARTBEAT.name + ".tmp")
pending.write_text(str(time.time()), encoding="ascii")
pending.replace(HEARTBEAT)
await asyncio.sleep(10) await asyncio.sleep(10)
@@ -26,5 +31,20 @@ def healthy():
return False return False
def start_watchdog(grace_seconds=75, interval=10):
"""Exit even when the event loop or driver cancellation itself is stuck."""
stopped = threading.Event()
started = time.monotonic()
def watch():
while not stopped.wait(interval):
if time.monotonic() - started >= grace_seconds and not healthy():
logging.getLogger(__name__).critical("Heartbeat expired; restarting the application")
os._exit(1)
threading.Thread(target=watch, name="heartbeat-watchdog", daemon=True).start()
return stopped
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(0 if healthy() else 1) raise SystemExit(0 if healthy() else 1)

View File

@@ -3,6 +3,7 @@ from src.utils.errors import public_error
from src.core.access import is_admin from src.core.access import is_admin
from src.utils.delivery import background_delivery from src.utils.delivery import background_delivery
from aiogram import Router, F from aiogram import Router, F
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
@@ -384,10 +385,10 @@ def _is_message_processed(chat_id: int, message_id: int) -> bool:
async def get_all_active_users(session: AsyncSession) -> List: async def get_all_active_users(session: AsyncSession) -> List:
"""Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)""" """Получить всех пользователей для рассылки (всем, кто когда-либо общался с ботом)"""
users = await UserService.get_all_users(session) from sqlalchemy import select
# Рассылаем всем пользователям - и зарегистрированным, и незарегистрированным from src.core.models import BlockedUser, User
# Они все имеют право общаться в чате (главное - что они вошли в чат) unavailable = select(BlockedUser.telegram_id).where(BlockedUser.is_active.is_(True))
return users return list((await session.scalars(select(User).where(User.telegram_id.not_in(unavailable)))).all())
async def broadcast_message_with_scheduler( async def broadcast_message_with_scheduler(
@@ -415,6 +416,7 @@ async def broadcast_message_with_scheduler(
async with async_session_maker() as session: async with async_session_maker() as session:
users = await get_all_active_users(session) users = await get_all_active_users(session)
users = [user for user in users if user.telegram_id != message.bot.id]
logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}") logger.info(f"[CHAT] broadcast_message_with_scheduler: всего пользователей для рассылки: {len(users)}")
@@ -502,6 +504,20 @@ async def _copy_with_sender(message: Message, recipient_id: int, sender_info: st
try: try:
sent = await copy_preserving_entities(message, recipient_id, sender_info or "Участник") sent = await copy_preserving_entities(message, recipient_id, sender_info or "Участник")
return sent.message_id return sent.message_id
except (TelegramForbiddenError, TelegramBadRequest) as error:
error_text = str(error).lower()
unavailable = isinstance(error, TelegramForbiddenError) or any(
reason in error_text for reason in ("chat not found", "user not found", "user is deactivated")
)
if unavailable:
from src.core.broadcast_services import BroadcastService
reason = "blocked_bot" if isinstance(error, TelegramForbiddenError) else "chat_not_found"
async with async_session_maker() as session:
await BroadcastService().mark_user_blocked(session, recipient_id, reason, str(error))
return None
import logging
logging.getLogger(__name__).warning("Telegram rejected chat message content: %s", error)
return None
except Exception: except Exception:
import logging import logging
logging.getLogger(__name__).exception("Chat delivery failed") logging.getLogger(__name__).exception("Chat delivery failed")

View File

@@ -2,6 +2,7 @@ import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest
from aiogram import Bot from aiogram import Bot
from aiogram.exceptions import TelegramRetryAfter from aiogram.exceptions import TelegramRetryAfter
@@ -95,3 +96,27 @@ async def test_inactivity_job_preserves_existing_delivery_block_and_continues():
assert await ActivityService.mark_inactive_users(session) == 1 assert await ActivityService.mark_inactive_users(session) == 1
assert await ActivityService.mark_inactive_users(session) == 0 assert await ActivityService.mark_inactive_users(session) == 0
assert await session.scalar(select(BlockedUser.error_type).where(BlockedUser.telegram_id == 201)) == "blocked_bot" assert await session.scalar(select(BlockedUser.error_type).where(BlockedUser.telegram_id == 201)) == "blocked_bot"
@pytest.mark.parametrize("error_kind", ["forbidden", "chat_not_found", "invalid_content"])
async def test_chat_skips_unreachable_recipients_and_restores_them_on_incoming_activity(monkeypatch, error_kind):
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.methods import SendMessage
from src.core.activity_service import ActivityService
from src.handlers import chat_handlers
async with async_session_maker() as session:
await UserService.get_or_create_user(session, 301)
await UserService.get_or_create_user(session, 302)
error = (TelegramForbiddenError if error_kind == "forbidden" else TelegramBadRequest)(
method=SendMessage(chat_id=301, text="test"),
message={"forbidden": "bot was blocked by the user", "chat_not_found": "chat not found",
"invalid_content": "CUSTOM_EMOJI_INVALID"}[error_kind],
)
monkeypatch.setattr(chat_handlers, "copy_preserving_entities", AsyncMock(side_effect=error))
assert await chat_handlers._copy_with_sender(SimpleNamespace(), 301, "Sender") is None
async with async_session_maker() as session:
recipients = {user.telegram_id for user in await chat_handlers.get_all_active_users(session)}
assert recipients == ({301, 302} if error_kind == "invalid_content" else {302})
await ActivityService.reactivate_user(session, 301)
assert {user.telegram_id for user in await chat_handlers.get_all_active_users(session)} == {301, 302}

31
tests/test_health.py Normal file
View File

@@ -0,0 +1,31 @@
import os
from pathlib import Path
import subprocess
import sys
def test_watchdog_exits_a_process_with_stalled_event_loop(tmp_path):
# A blocked event loop cannot run coroutine-based timeout handlers.
code = """import time
from src.core.health import start_watchdog
start_watchdog(grace_seconds=0.1, interval=0.02)
time.sleep(10)
"""
result = subprocess.run([sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[1],
env=dict(os.environ, HEARTBEAT_FILE=str(tmp_path / "absent")),
capture_output=True, timeout=8)
assert result.returncode == 1
assert b"Heartbeat expired" in result.stderr
def test_stopped_watchdog_allows_normal_shutdown(tmp_path):
code = """import time
from src.core.health import start_watchdog
stopped = start_watchdog(grace_seconds=0.1, interval=0.02)
stopped.set()
time.sleep(0.2)
"""
result = subprocess.run([sys.executable, "-c", code], cwd=Path(__file__).resolve().parents[1],
env=dict(os.environ, HEARTBEAT_FILE=str(tmp_path / "absent")),
capture_output=True, timeout=8)
assert result.returncode == 0