Перейти к основному содержимому

Корректная остановка

Сервер 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 серверу, завершающему подписку по собственной инициативе, СЛЕДУЕТ сначала отправить пустой результат, чтобы клиент отличил упорядоченное завершение от оборванного соединения. Чтобы его доставить, остановка стала двухфазной:

  1. Сигнал завершает подписки, и сервер ждёт, пока реестр не опустеет и ни одно сообщение не останется внутри конвейера middleware — вместе это значит, что каждый результат дошёл до исходящего канала.
  2. Только после этого разбирается транспорт, а писатели дренируют очередь перед выходом.

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 самого движка, поэтому движок, который берёт токен и ничего с ним не делает, тратит на каждой остановке весь бюджет.

Обучение на примерах​