Корректная остановка
Сервер neva останавливается по сигналу ОС — SIGINT / SIGTERM и их аналогам
в Windows — вообще без настройки. Для процесса, вся работа которого — быть
MCP-сервером, это правильное поведение по умолчанию, и совершенно бесполезное
для двух случаев, которые к этому не сводятся: для теста, которому нужно
наблюдать упорядоченную остановку, и для neva, встроенной в более крупный
сервис со своим жизненным циклом.
Для них App::with_shutdown(),
with_shutdown_signal(..) и with_shutdown_drain(..) дают остановку явно —
handle.abort() для задачи сервера по построению пропустил бы все корректные
пути.
Остановка без сигнала
with_shutdown() возвращает
ShutdownHandle
вместе с приложением:
use neva::prelude::*;
#[tokio::main]
async fn main() {
let (app, shutdown) = App::new()
.with_options(|opt| opt.with_default_http())
.with_shutdown();
let server = tokio::spawn(app.run());
// ... позже, откуда угодно:
shutdown.shutdown();
server.await.expect("the server task panicked");
}
Хендл дополняет обработчик сигналов, а не заменяет его — срабатывает тот, кто первый, — так что построенный так сервер по-прежнему останавливается по Ctrl+C.
Хендл остановку запрашивает, а не завершает: shutdown() возвращает
управление, как только запрос зафиксирован. Дождитесь run(), чтобы узнать,
что сервер действительно закончил.
Общий сигнал, которым вы уже владеете
Внутри сервиса со своим жизненным циклом возьмите сигнал у этого сервиса, а не раздавайте свой:
use neva::prelude::*;
use neva::app::ShutdownHandle;
let shutdown = ShutdownHandle::new();
let app = App::new().with_shutdown_signal(shutdown.clone());
let server = tokio::spawn(app.run());
shutdown.shutdown();
server.await.expect("the server task panicked");
| Метод | Описание |
|---|---|
ShutdownHandle::new() | Хендл с новым собственным сигналом |
ShutdownHandle::from_token(token) | Оборачивает существующий tokio_util::sync::CancellationToken, чтобы сервер останавливался по сигналу другой подсистемы |
handle.shutdown() | Запрашивает остановку. Идемпотентен |
handle.is_shutdown_requested() | Была ли остановка запрошена — не завершена |
Клоны разделяют один сигнал: любой клон, вызвавший shutdown(), останавливает
тот сервер, от которого хендл получен.
Что происходит при остановке
В MCP 2026-07-28 серверу, завершающему подписку по собственной инициативе, СЛЕДУЕТ сначала отправить пустой результат, чтобы клиент отличил упорядоченное завершение от оборванного соединения. Чтобы его доставить, остановка стала двухфазной:
- Сигнал завершает подписки, и сервер ждёт, пока реестр не опустеет и ни одно сообщение не останется внутри конвейера middleware — вместе это значит, что каждый результат дошёл до исходящего канала.
- Только после этого разбирается транспорт, а писатели дренируют очередь перед выходом.
with_shutdown_drain(..) ограничивает всю процедуру:
use std::time::Duration;
App::new()
.with_shutdown_drain(Duration::from_secs(5))
.with_options(|opt| opt.with_default_http())
.run()
.await;
| По умолчанию | 2 секунды |
| Это потолок, а не задержка | Ожидание заканчивается в момент, когда поставлен в очередь последний результат, и полностью пропускается, если ни одной подписки нет — сервер, который ими не пользуется, останавливается ровно так же быстро, как раньше |
Duration::ZERO | Отказ от механизма, возврат к резкому закрытию |
Поднимите значение для сервера, у которого подписки сбрасывают глубокие буферы.
Дедлайн ставится в момент прихода запроса на остановку. Ожидание ответов подписок тратит его часть; писателям достаётся остаток. То, что ещё пишет, когда бюджет исчерпан, останавливается, а не остаётся на runtime, который может пережить сервер.
Под run_blocking
run_blocking()
создаёт runtime, запускает на нём сервер и уничтожает runtime сразу, как только
run вернёт управление, — поэтому всё, что ещё дренируется в отсоединённой
задаче, было бы прервано на середине записи. run дожидается писателей
транспорта, прежде чем вернуться, — именно это делает дренирование одинаковым
для обоих способов запуска.
HTTP-движок тоже обязан остановиться
HttpEngine получает CancellationToken и обязан опустить
свой слушающий сокет, когда токен сработает. На этом контракте и держится
дренирование: run дожидается возврата из run самого движка, поэтому
движок, который берёт токен и ничего с ним не делает, тратит на каждой
остановке весь бюджет.