CLI приложения #19. Правильная обработка Ctrl+C
Любое долгое приложение рано или поздно прерывают. Пользователь понял, что запустил не то, или просто передумал ждать — и нажал Ctrl+C.
По умолчанию .NET на этом месте убивает процесс. Мгновенно, без предупреждения. Код после текущей строки не выполнится, блоки finally не отработают.
И вот тут выясняется, что мы наделали за предыдущие посты. Курсор спрятан — он так и останется невидимым. Временный файл не удалён — он так и останется на диске. Соединение с базой не закрыто, а в худшем случае прерывание пришлось на середину записи файла, и теперь там половина данных.
Правильное поведение — перехватить прерывание, спокойно свернуть работу и выйти.
В обычном консольном приложении перехват делается через событие:
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true; // не убивать процесс немедленно
Console.Error.WriteLine("Останавливаюсь...");
};
Ключевая строка — e.Cancel = true. Она говорит среде выполнения: прерывание обработано, завершать процесс не нужно. Без неё обработчик выполнится, но программа всё равно умрёт.
Само по себе это бесполезно: нажатие мы перехватили, а работа продолжается. Нужно сообщить рабочему коду, что пора закругляться. В .NET для этого есть CancellationToken.
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true;
cts.Cancel();
};
CancellationTokenSource — это переключатель, а CancellationToken — его доступная только для чтения сторона, которую мы передаём в рабочий код. Один вызов Cancel() — и все, кто держит токен, об этом узнают.
Дальше рабочий код должен токен проверять:
foreach (var file in files)
{
token.ThrowIfCancellationRequested();
ProcessFile(file);
}
Метод ThrowIfCancellationRequested бросит OperationCanceledException, если переключатель сработал. Ставить проверку нужно там, где прерывание безопасно — между файлами, а не посреди записи одного из них.
Важно!
Если вы используете System.CommandLine, всё описанное выше библиотека делает сама. Она подписывается на Ctrl+C, SIGINT и SIGTERM и сама отменяет токен — свой обработчик писать не нужно, он будет только мешать. Токен приходит вторым параметром в асинхронную перегрузку SetAction:
command.SetAction(async (parseResult, token) =>
{
await DoWorkAsync(token);
return ExitCode.Success;
});
А запускается такое действие соответствующим методом:
return await rootCommand.Parse(args).InvokeAsync();
Ещё одна деталь, о которой почти никто не знает. На уборку после прерывания библиотека даёт всего две секунды. Не уложились — процесс убивают принудительно, и finally не доработает. Если вы закрываете соединения или дописываете индекс, время стоит увеличить:
var config = new InvocationConfiguration
{
ProcessTerminationTimeout = TimeSpan.FromSeconds(10)
};
return await rootCommand.Parse(args).InvokeAsync(config);
И про код возврата. Прерванная по Ctrl+C программа по соглашению возвращает 130: это 128 плюс номер сигнала прерывания, а он равен двум. Скрипты по этому числу отличают «пользователь передумал» от «программа сломалась». Мы упоминали этот диапазон в пятом посте — вот он и пригодился.
Механика понятна, но остаётся вопрос: где всё это должно жить в приложении? В следующем посте разберём на полном примере.