From c7f2080604940642c0a0ed559b9f48c9e4afc662 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 9 Sep 2026 02:44:36 +0300 Subject: [PATCH] Leave voice before closing the server on shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlreadyConnected kept coming back after every rebuild. Shutdown closed the HTTP server first, which blocks while the panel's WebSocket is open, so Docker's grace period ran out and the process was killed before it ever left the voice channels — and Stoat only clears a bot's voice state when it disconnects, leaving the stale entry that refuses the next join. Voice channels are now left first, and each shutdown step has its own deadline so neither can eat the grace period. A failed join_call also escaped as an unhandled rejection with a full stack, because revoice awaits it inside an async executor it never guards; the error is already latched and reported, so that call no longer settles at all. Co-Authored-By: Claude Opus 5 --- README.md | 10 ++++++++-- src/core/revoice.ts | 5 +++++ src/index.ts | 28 ++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 10 deletions(-) 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"));