Процесс запуска и ручное управление
Методы await sdk.run() / await sdk.init() ErisPulse объединяют весь процесс запуска в одну строку кода. Однако, если вам нужно полностью настроить процесс запуска (например, частичная загрузка, динамическая регистрация, горячая замена, встраивание пользовательской стратегии загрузки), вам нужно понимать, что происходит внутри этой цепочки, а также как вручную управлять каждым шагом.
В этой статье мы разбиваем процесс запуска на отдельные этапы, объясняем их назначение и порядок вызова, а также приводим пример ручного полного запуска.
В этой статье предполагается, что вы уже запустили первого робота и знакомы с двумя режимами
sdk.run(keep_running=True/False). В этой статье мы сосредоточимся на разборе внутренней цепочкиinit(), а также на более низкоуровневых входных точках, таких какinit()/init_task()/init_sync().
Обзор верхнего уровня SDK
Помимо двух режимов keep_running для run(), SDK предоставляет несколько более низкоуровневых точек входа, которые отличаются асинхронностью, возвращаемым значением и обработкой исключений:
| Точка входа | Асинхронность | Возвращаемое значение | Обработка исключений | Сценарий использования |
|---|---|---|---|---|
await sdk.run(True) |
async, блокирующий | None (автоматически uninit при остановке) |
Ошибки модулей/адаптеров перехватываются, не приводят к сбою процесса | Простое приложение-бот |
await sdk.run(False) |
async, не блокирующий | None (не автоматически выгружается) |
То же самое | Инициализация, затем выполнение пользовательской логики |
await sdk.init() |
async, требует await |
bool |
Внутренние исключения компонентов перехватываются, в случае неудачи возвращается False |
Ручное управление жизненным циклом (с uninit()) |
sdk.init_task() |
async, возвращает Task без блокировки |
asyncio.Task |
То же, что и init() |
Параллельное выполнение других инициализаций или когда цикл событий еще не запущен |
sdk.init_sync() |
Синхронный, блокирует текущий поток | bool |
То же, что и init() |
Сценарии командной строки, синхронный вход без цикла событий |
Распространённое заблуждение:
await sdk.init()не эквивалентноawait sdk.run(keep_running=False). Есть два различия: ①init()возвращаетbool(в случае неудачи возвращаетFalse),run()возвращаетNone; ②init()выполняет только инициализацию и не производит автоматическую выгрузку,run()автоматически вызываетuninit()при завершении цикла событий. Поэтому, если требуется ручное управление жизненным циклом или выгрузкой, используйтеinit()+uninit().
Обзор запуска
sdk.init() (точнее, его внутренний Initializer.init()) запускает всю систему в следующем порядке:
flowchart TD
A[0. Подготовка среды<br/>Загрузка конфигурации / Обработка исключений] --> B
B[1. Параллельное обнаружение и загрузка<br/>AdapterLoader.load / ModuleLoader.load<br/>Внутренний вызов Finder.find_all] --> C
C[2. Регистрация адаптера<br/>AdapterLoader.register_to_manager] --> D
D[3. Запуск адаптера<br/>adapter.startup] --> E
E[4. Регистрация модуля<br/>ModuleLoader.register_to_manager] --> F
F[5. Инициализация модуля<br/>ModuleLoader.initialize_modules<br/>Создание экземпляра и привязка к sdk] --> G
G[6. Запуск сервера маршрутизации<br/>router.start]
Соответствующие основные компоненты:
| Уровень | Компонент | Ответственность |
|---|---|---|
| Обнаружение | AdapterFinder / ModuleFinder |
Обнаружение адаптеров/модулей из entry-points установленных пакетов |
| Загрузка | AdapterLoader / ModuleLoader |
Обнаружение + импорт + чтение метаданных + проверка включения/отключения, возвращает список объектов |
| Регистрация | *Loader.register_to_manager |
Запись объектов в соответствующий менеджер |
| Управление | sdk.adapter / sdk.module |
Хранение экземпляров адаптеров/модулей, предоставление интерфейсов запуска/остановки |
| Инициализация | ModuleLoader.initialize_modules |
Создание экземпляров модулей и привязка к sdk (обработка топологической сортировки зависимостей) |
| Маршрутизация | sdk.router |
HTTP / WebSocket сервер |
Важно:
FinderиLoader— это два уровня.Loaderвнутри уже содержитFinder(AdapterLoaderсодержитAdapterFinder,ModuleLoaderсодержитModuleFinder). В большинстве случаев вам достаточно использоватьLoader, аFinderприменяется только тогда, когда нужно "только перечислить, но не импортировать".
Подробное описание каждого этапа
1. Уровень обнаружения: Finder
Finder отвечает только за "поиск пакетов, предоставляющих адаптеры/модули", не импортирует и не создает экземпляры.
from ErisPulse.finders import AdapterFinder, ModuleFinder
adapter_finder = AdapterFinder()
module_finder = ModuleFinder()
# Поиск всех установленных entry-points для адаптеров/модулей
adapter_entries = adapter_finder.find_all() # list[EntryPoint]
module_entries = module_finder.find_all() # list[EntryPoint]
# Поиск по имени
entry = module_finder.find_by_name("MyModule") # EntryPoint | None
Каждый EntryPoint можно загрузить через .load(), чтобы получить соответствующий класс, но обычно это делает Loader автоматически.
2. Уровень загрузки: Loader
Loader на основе Finder выполняет "импорт + чтение метаданных + проверку включения/отключения".
from ErisPulse.loaders import AdapterLoader, ModuleLoader
from ErisPulse import sdk
adapter_loader = AdapterLoader()
module_loader = ModuleLoader()
# Внутри load(): вызывается finder.find_all() → обработка entry-point → возврат тройки
adapter_objs, enabled_adapters, disabled_adapters = await adapter_loader.load(sdk.adapter)
module_objs, enabled_modules, disabled_modules = await module_loader.load(sdk.module)
Тройка, возвращаемая load():
| Возвращаемое значение | Значение |
|---|---|
objs (dict) |
Сопоставление названия → объекта (класс адаптера / обёртка модуля) |
enabled (list[str]) |
Названия, включённые (не отключены в конфигурации) |
disabled (list[str]) |
Названия, отключённые |
Информация о диагностике при сбое загрузки
Если при загрузке или инициализации модуля/адаптера возникает исключение, фреймворк пропускает этот компонент и продолжает загрузку остальных, выводя сводку пользовательских фреймов кода, чтобы вы могли локализовать проблему на уровне INFO, без необходимости вручную включать DEBUG:
[ERROR] [ModuleLoader] Загрузка модуля MyModule из entry-point не удалась, пропущено: 'NoneType' object has no attribute 'platform'
→ MyModule/Core.py:42 in on_load
adapter = sdk.platform
→ AttributeError: 'NoneType' object has no attribute 'platform'
→ Подсказка: Увеличьте уровень логирования до DEBUG для просмотра полного стека; проверьте реализацию модуля MyModule
Диагностическая информация генерируется через модуль ErisPulse.runtime.diagnostics и автоматически фильтрует внутренние фреймы фреймворка, оставляя только фреймы вашего кода. При необходимости можно повторно использовать это в пользовательской логике загрузки:
from ErisPulse.runtime import log_diagnostic
try:
risky_init()
except Exception as e:
log_diagnostic(e) # Автоматически извлекает фреймы пользовательского кода и записывает в ERROR-лог
Модуль также предоставляет два вспомогательных метода: extract_user_frame() (возвращает структурированную информацию о фрейме) и format_diagnostic_block() (возвращает многострочную текстовую строку).
3. Регистрация: register_to_manager
Регистрация объектов, созданных Loader, в менеджере, чтобы sdk.adapter / sdk.module могли их распознавать.
# Регистрация адаптеров (возвращает bool, означающий, успешно ли все прошли)
await adapter_loader.register_to_manager(enabled_adapters, adapter_objs, sdk.adapter)
# Регистрация модулей
await module_loader.register_to_manager(enabled_modules, module_objs, sdk.module)
После регистрации адаптеры зарегистрированы в менеджере адаптеров, а модули — в менеджере модулей, но ни один из них ещё не запущен/не создан экземпляр.
4. Запуск адаптеров
# Запуск всех зарегистрированных адаптеров
await sdk.adapter.startup()
# Или указать платформу
await sdk.adapter.startup("yunhu")
await sdk.adapter.startup(["yunhu", "telegram"])
Регистрация ≠ Запуск.
register_to_manager— это просто регистрация;startupвызывает методstart()адаптера и устанавливает соединение с платформой.
5. Инициализация модулей
Модули требуют дополнительного шага — инициализацию и привязку к sdk (чтобы вы могли обращаться к sdk.MyModule.xxx). Этот шаг также обрабатывает зависимости между модулями и сортирует их в топологическом порядке.
success = await module_loader.initialize_modules(
enabled_modules, module_objs, sdk.module, sdk
)
После успешной инициализации модуль появится в sdk.<ModuleName>.
6. Запуск сервера маршрутизации
await sdk.router.start(
host="0.0.0.0",
port=8000,
ssl_certfile=None,
ssl_keyfile=None,
)
Сервер маршрутизации отвечает за получение обратных вызовов Webhook / WebSocket от адаптеров. Без запуска сервера, адаптеры в режиме сервера не смогут получать сообщения.
Полный пример ручного запуска
Следующий код эквивалентен основному процессу await sdk.init(), но каждая его часть доступна для ручного управления, что позволяет вставить пользовательскую логику в любой момент:
import asyncio
from ErisPulse import sdk
from ErisPulse.loaders import AdapterLoader, ModuleLoader
async def manual_startup():
# 0. Подготовка окружения (загрузка конфигурации, регистрация обработки исключений)
# _prepare_environment — это внутренний шаг init(); если использовать ручной процесс,
# его необходимо вызвать до Loader, иначе Loader не сможет прочитать конфигурацию,
# и все адаптеры/модули будут ошибочно признаны отключенными.
if not await sdk._prepare_environment():
print("Ошибка при подготовке окружения")
return False
# 1. Создание загрузчиков (внутри каждый загрузчик содержит Finder)
adapter_loader = AdapterLoader()
module_loader = ModuleLoader()
# 2. Параллельное обнаружение и загрузка (аналогично внутреннему gather в init())
(adapter_objs, enabled_adapters, disabled_adapters), \
(module_objs, enabled_modules, disabled_modules) = await asyncio.gather(
adapter_loader.load(sdk.adapter),
module_loader.load(sdk.module),
)
# 3. Регистрация адаптеров
await adapter_loader.register_to_manager(
enabled_adapters, adapter_objs, sdk.adapter
)
# 4. Запуск адаптеров
if enabled_adapters:
await sdk.adapter.startup()
# 5. Регистрация модулей
await module_loader.register_to_manager(
enabled_modules, module_objs, sdk.module
)
# 6. Инициализация модулей (инстанцирование + привязка к sdk)
if enabled_modules:
await module_loader.initialize_modules(
enabled_modules, module_objs, sdk.module, sdk
)
# 7. Запуск сервера маршрутизации
await sdk.router.start(host="0.0.0.0", port=8000)
print("Ручной запуск завершен")
return True
async def main():
ok = await manual_startup()
if ok:
# Блокировка для поддержания работы (ручной процесс не блокирует автоматически)
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())
Когда использовать ручной запуск?
В большинстве случаев ручной запуск не требуется, так как await sdk.run() уже выполняет все вышеперечисленные шаги. Ручной запуск имеет смысл только в следующих сценариях:
- Частичная загрузка: загрузить только указанные адаптеры/модули, пропустив остальные
- Динамическая регистрация: регистрировать новые адаптеры/модули во время выполнения в зависимости от условий
- Собственный порядок: изменить стандартный порядок загрузки (например, запустить модуль до запуска адаптера)
- Внедрение стратегий: внедрить в Loader пользовательские менеджеры строгого режима, стратегии загрузки и т. д.
- Отладка/диагностика: при сбое на каком-либо этапе, ручной запуск позволяет локализовать проблему
Контроль на уровне выполнения
Даже после использования sdk.run() для запуска, вы всё ещё можете управлять отдельными подсистемами в процессе выполнения, без необходимости перезапуска всего SDK:
Горячая перезагрузка адаптеров
# Горячая перезагрузка одного из адаптеров (исправление подключения, не влияет на другие платформы)
await sdk.adapter.shutdown("yunhu")
await sdk.adapter.startup("yunhu")
# Запуск новой платформы в процессе выполнения
await sdk.adapter.startup("telegram")
# Временное отключение определённой платформы
await sdk.adapter.shutdown("telegram")
adapter.startup()требует, чтобы адаптер был зарегистрирован в менеджере. Регистрация происходит внутриinit()/run(), поэтому это и есть тонкое управление, которое возможно после запуска.
Сервер маршрутизации
# Временное отключение сервера webhook
await sdk.router.stop()
# Перезапуск (например, при смене порта)
await sdk.router.start(host="0.0.0.0", port=9000)
Загрузка модулей по требованию
# Ручная загрузка модуля (возможно, ленивой загрузки)
await sdk.load_module("MyModule")
Грациозное завершение
Начиная с версии 2.7.0, sdk.shutdown() обеспечивает программное грациозное завершение: установка события завершения, которое заставляет основной цикл, зависший в await sdk.run(keep_running=True), вернуться, что приводит к запуску uninit() и завершению очистки ресурсов.
# Вызывается в любой корутине, вызывает грациозный выход (run() зависший возвращает и автоматически выполняет uninit)
sdk.shutdown()
Типичное применение:
async def shutdown_after_idle():
await asyncio.sleep(3600)
sdk.shutdown() # Грациозное завершение после 1 часа бездействия
Обработка сигналов: run() внутри регистрирует обработчики SIGTERM / SIGHUP, преобразуя системные сигналы в грациозное завершение — при остановке контейнеров (Docker docker stop) или остановке службы systemd процесс будет выполнять uninit() для очистки, а не будет принудительно убит.
- На Windows не поддерживается
loop.add_signal_handler, обработчик сигналов будет автоматически пропущен (можно использоватьsdk.shutdown()или Ctrl+C для вызова завершения) - Повторный вызов
sdk.shutdown()безопасен (при уже установленном событии повторные вызовы не выполняют никаких действий)
Процесс удаления
Обратной операцией запуска является await sdk.uninit(), которая очищает в обратном порядке:
- Закрытие всех адаптеров (
adapter.shutdown()) - Удаление всех модулей
- Очистка всех обработчиков событий
- Очистка модульных свойств менеджеров и SDK
В ручных сценариях запуска не забудьте вызвать uninit() перед выходом для обеспечения корректного завершения:
try:
await asyncio.Event().wait() # Поддержание работы
finally:
await sdk.uninit()
Перезапуск
SDK предоставляет два способа перезапуска, и вам не нужно самостоятельно сначала удалять — фреймворк сам обрабатывает:
| Способ | Вызов | Поведение | Сценарии использования |
|---|---|---|---|
| Горячий перезапуск | await sdk.restart() |
Внутри одного процесса uninit() и повторный init(), повторная загрузка адаптеров/модулей |
Перезагрузка конфигурации, горячая замена модулей |
| Жесткий перезапуск | await sdk.hard_restart() |
uninit() и выход из процесса с кодом 42, запуск нового процесса внешним наблюдателем |
Подозрение на утечку памяти/ресурсов, полный чистый перезапуск |
# Горячий перезапуск: повторная загрузка в том же процессе (наиболее часто используемый)
await sdk.restart()
# Жесткий перезапуск: выход из процесса, передача управления внешнему наблюдателю (см. руководство по наблюдателям ниже)
await sdk.hard_restart()
Два важных замечания:
- Оба этих метода выполняются в фоновой задаче, немедленно возвращают
True, означая, что задача перезапуска запланирована, а не что перезапуск уже завершен. Фактический перезапуск происходит в фоне, чтобы не прерывать текущую цепочку событий.hard_restart()работает следующим образом: после отключения и сохранения конфигурации, процесс завершается с кодом 42 (HARD_RESTART_EXIT_CODE) — он сам не запускает новый процесс, необходимо, чтобы внешний наблюдатель обнаружил код 42 и перезапустил процесс. Если процесс завершается с кодом 42 и не запущен никаким наблюдателем, например, при запуске напрямую черезpython main.py, процесс завершится и не будет автоматически перезапущен (фреймворк выдаст предупреждение).
Когда использовать жесткий перезапуск?
Жесткий перезапуск — это не просто "более полный перезапуск", он более подходит и даже эффективнее в следующих случаях:
- Побочные эффекты двоичных библиотек (C-расширений): Горячий перезапуск происходит в одном процессе, не позволяя освободить ресурсы, такие как C-расширения, открытые файловые дескрипторы, потоки и т.д. Жесткий перезапуск создает новый процесс, полностью устраняя эти побочные эффекты.
- Выявление утечек ресурсов: При подозрении на утечку памяти или дескрипторов, жесткий перезапуск дает чистую среду.
- Частые перезапуски, чувствительные к производительности: Жесткий перезапуск исключает накладные расходы на отключение и повторную загрузку в одном процессе, фактически более эффективен, чем горячий перезапуск.
Функция "Перезапуск фреймворка" в панели управления Dashboard вызывает
hard_restart().
Контракт с кодом выхода 42
Жесткий перезапуск — это сотрудничество между процессами: SDK отвечает за выход (код 42), наблюдатель за запуск.
| Роль | Поведение |
|---|---|
| SDK (при жестком перезапуске) | uninit() → сохранение конфигурации → os._exit(42) |
| Наблюдатель | Обнаружение кода выхода подпроцесса 42 → перезапуск той же команды |
sdk.is_supervised()позволяет проверить, был ли текущий процесс запущен наблюдателем (проверка переменной окруженияERISPULSE_SUPERVISED). Команда CLIrunавтоматически вставляет этот маркер при запуске подпроцесса; внешние наблюдатели, такие как systemd или Docker, не вставляют его, иis_supervised()возвращаетFalse, в этом случае после жесткого перезапуска фреймворк выдаст предупреждение "Наблюдатель не обнаружен".
Руководство по наблюдателям
Выберите подходящего наблюдателя, чтобы жесткий перезапуск действительно сработал:
1. Команда CLI run (для разработки/простых развертываний, рекомендуется)
epsdk run main.py содержит встроенную циклическую проверку: обнаружение кода выхода подпроцесса, перезапуск при коде 42; при других аномальных кодах выхода происходит автоматическая повторная попытка с экспоненциальной задержкой; Ctrl+C сначала корректно завершает подпроцесс (код 0 считается нормальным выходом, перезапуск не происходит).
epsdk run main.py
2. systemd (Linux-сервера)
RestartForceExitStatus=42 позволяет перезапускать при коде выхода 42 (по умолчанию on-failure срабатывает только при ненулевом коде):
[Service]
ExecStart=/usr/bin/python3 /opt/mybot/main.py
Restart=on-failure
RestartForceExitStatus=42
RestartSec=2
User=mybot
3. Docker / docker-compose
Внутри контейнера PID 1 — это процесс приложения, после кода выхода 42 контейнер завершается — используйте стратегию restart, чтобы он автоматически перезапускался:
services:
bot:
build: .
restart: unless-stopped # перезапуск при любом выходе (включая 42)
4. PM2 (экосистема Node.js для администрирования)
pm2 start main.py --name mybot --interpreter python3
# 42 считается кодом выхода, PM2 по умолчанию перезапускает; установите restart_delay для предотвращения дребезга
pm2 set mybot.restart_delay 2000
5. supervisord
[program:mybot]
command=python3 /opt/mybot/main.py
autorestart=true
exitcodes=0,2,42 # 42 также считается "нормальным выходом, требующим перезапуска"
6. Собственный наблюдатель на чистом Python
import subprocess, sys, time
while True:
p = subprocess.Popen([sys.executable, "main.py"])
code = p.wait()
if code == 42: # Запрос на жесткий перезапуск
time.sleep(0.5)
continue
if code == 0: # Нормальный выход
break
time.sleep(3) # Аномальный выход, задержка перед повторной попыткой
Поведение без наблюдателя: При запуске напрямую через
python main.py, вызовhard_restart()приведет к завершению процесса с кодом 42 и не будет автоматического перезапуска. В этом случае необходимо подключить один из вышеперечисленных наблюдателей.
Связанные документы
- Создание первого бота - Основные режимы
keep_runningдля начала работы - Управление жизненным циклом - Обработка событий запуска
core.init.start/core.init.completeи других - Ленивая загрузка системы - Механизм ленивой загрузки модулей и
load_module