CLI приложения #20. Отмена: полный пример
В прошлом посте разобрали механику прерывания по частям. Теперь соберём рабочее приложение и посмотрим, что где объявляется.
Правило распределения такое: токен создаёт инфраструктура, проверяет его рабочий код, а обрабатывает прерывание команда. Сервис ничего не знает ни про консоль, ни про коды возврата — он только бросает исключение. Команда не знает, как устроена работа — она ловит исключение и убирает за собой.

Схема передачи токена отмены
Разложим по файлам. Program.cs — сборка приложения и настройка поведения при прерывании:
using System.CommandLine;
using System.Text;
internal static class ExitCode
{
public const int Success = 0;
public const int FileNotFound = 2;
public const int Interrupted = 130;
}
internal class Program
{
private static async Task<int> Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
var rootCommand = new RootCommand("Учебное CLI приложение");
rootCommand.Subcommands.Add(SyncCommand.Create());
var config = new InvocationConfiguration
{
// времени на уборку после Ctrl+C, по умолчанию всего 2 секунды
ProcessTerminationTimeout = TimeSpan.FromSeconds(10)
};
return await rootCommand.Parse(args).InvokeAsync(config);
}
}
Commands/SyncCommand.cs — описание команды и обработка прерывания:
using System.CommandLine;
internal static class SyncCommand
{
public static Command Create()
{
var sourceArgument = new Argument<string>("source")
{
Description = "Папка с исходными файлами"
};
var outputOption = new Option<string>("--output", "-o")
{
Description = "Файл результата",
DefaultValueFactory = _ => "result.json"
};
var command = new Command("sync", "Собрать сведения о файлах");
command.Arguments.Add(sourceArgument);
command.Options.Add(outputOption);
// токен приходит вторым параметром, его даёт библиотека
command.SetAction(async (parseResult, token) =>
{
var source = parseResult.GetValue(sourceArgument)!;
var output = parseResult.GetValue(outputOption)!;
if (!Directory.Exists(source))
{
Console.Error.WriteLine($"Папка не найдена: {source}");
return ExitCode.FileNotFound;
}
// пишем рядом с целевым файлом, чтобы переименование
// осталось в пределах одного диска
var fullOutput = Path.GetFullPath(output);
var tempFile = fullOutput + ".tmp";
try
{
var service = new FileSyncService();
await service.RunAsync(source, tempFile, token);
File.Move(tempFile, fullOutput, overwrite: true);
Console.Error.WriteLine($"Готово: {output}");
return ExitCode.Success;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Операция прервана пользователем");
return ExitCode.Interrupted;
}
finally
{
// выполнится и при успехе, и при прерывании
if (File.Exists(tempFile))
File.Delete(tempFile);
}
});
return command;
}
}
Services/FileSyncService.cs — рабочая логика:
internal sealed class FileSyncService
{
public async Task RunAsync(string source, string tempFile, CancellationToken token)
{
var files = Directory.GetFiles(source);
await using var writer = new StreamWriter(tempFile);
await writer.WriteLineAsync("[");
for (var i = 0; i < files.Length; i++)
{
// безопасная точка прерывания: между файлами, а не внутри записи
token.ThrowIfCancellationRequested();
var info = new FileInfo(files[i]);
var comma = i < files.Length - 1 ? "," : "";
var line = $" {{\"name\":\"{info.Name}\",\"size\":{info.Length}}}{comma}";
await writer.WriteLineAsync(line.AsMemory(), token);
// имитация долгой работы, чтобы успеть нажать Ctrl+C
await Task.Delay(500, token);
}
await writer.WriteLineAsync("]");
}
}
Проверяем:
dotnet run -- sync . -o result.json
# и нажимаем Ctrl+C в процессе
После прерывания в папке не должно остаться ни result.json, ни result.json.tmp.
Важно!
Обратите внимание, что токен передаётся и в
Task.Delay, и вWriteLineAsyncпоследним параметром. Это даже важнее, чемThrowIfCancellationRequested: без этого прерывание сработает только после завершения текущей паузы. В реальном коде на её месте будет сетевой запрос, и ждать придётся куда дольше.
И ещё одна тонкость: временный файл кладётся рядом с целевым, а не в системную папку Temp. Переименование атомарно только в пределах одного тома. Если Temp окажется на другом диске, File.Move превратится в копирование, и смысл приёма потеряется.
Подход рабочий и для чего угодно другого: отмена проходит сквозь все слои, а уборка живёт там же, где создаются ресурсы.
Дальше займёмся настройками: где хранить конфиг, как его искать и в каком порядке применять источники.