diff --git a/README.md b/README.md index 0bd3e89..89ad758 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,14 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f docker compose exec redis valkey-cli SREM 'vc_members:' '' ``` - В норме состояние снимает `voice-ingress` по вебхуку от LiveKit — если ситуация повторяется - после каждого перезапуска, смотрите `docker compose logs voice-ingress`. + Решает дело именно `DEL vc:` — он должен вернуть `(integer) 1`. `SREM` часто + возвращает `0`, это нормально: участники там хранятся в другом виде. + + Само состояние снимает Stoat, когда бот отключается от LiveKit, поэтому важно, чтобы бот + успевал корректно выйти при остановке контейнера — он выходит из каналов первым делом, с + отдельным лимитом времени. Если проблема всё же повторяется после каждого перезапуска, + смотрите `docker compose logs voice-ingress`: снимать состояние по вебхуку от LiveKit — + его работа. 4. **Бот в канале, но звука нет.** Это уже медиа-трафик: LiveKit анонсирует клиентам свой адрес из `rtc.node_ip` / `use_external_ip` в `/opt/stoat/livekit.yml` и ждёт UDP на 50000-50100. Если анонсируется внешний IP, а роутер не умеет NAT loopback, пакеты от контейнера до него не diff --git a/src/core/revoice.ts b/src/core/revoice.ts index 12012e3..6b1cd89 100644 --- a/src/core/revoice.ts +++ b/src/core/revoice.ts @@ -108,6 +108,11 @@ export function createRevoice(token: string, baseURL: string, node: string): Rev return await post(path, payload, params); } catch (err) { pendingError = describeApiError(err); + // revoice awaits this inside an async executor it never guards, so a + // rejection here escapes as an unhandled rejection with a full stack for + // what is an ordinary, already-reported error. Our join() wrapper below + // picks the latched error up, so this call simply never settles. + if (path.endsWith("/join_call")) return new Promise(() => {}); throw pendingError; } }; diff --git a/src/index.ts b/src/index.ts index ca9e300..c1dcd2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,16 +59,28 @@ async function main(): Promise { const bot = await startBot(manager); const app = await startApiServer({ manager, context: bot.context }); + /** Never let a slow step eat the container's grace period. */ + const withDeadline = async (task: Promise, ms: number, what: string): Promise => { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + logger.warn({ step: what }, "shutdown step timed out, moving on"); + resolve(); + }, ms); + }); + await Promise.race([task.catch((err) => logger.error({ err, step: what }, "shutdown step failed")), deadline]); + if (timer) clearTimeout(timer); + }; + const shutdown = async (signal: string): Promise => { logger.info({ signal }, "shutting down"); - try { - await app.close(); - await bot.stop(); - } catch (err) { - logger.error({ err }, "shutdown failed"); - } finally { - process.exit(0); - } + // Leaving voice comes first and gets its own deadline: Stoat only clears the + // bot's voice state when it disconnects, and a state left behind makes the + // next join fail with AlreadyConnected. Closing the HTTP server can block on + // an open panel WebSocket, which used to eat the whole grace period. + await withDeadline(bot.stop(), 5000, "leave voice channels"); + await withDeadline(app.close(), 3000, "close http server"); + process.exit(0); }; process.on("SIGINT", () => void shutdown("SIGINT"));