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 плюс номер сигнала прерывания, а он равен двум. Скрипты по этому числу отличают «пользователь передумал» от «программа сломалась». Мы упоминали этот диапазон в пятом посте — вот он и пригодился.

Механика понятна, но остаётся вопрос: где всё это должно жить в приложении? В следующем посте разберём на полном примере.