CLI приложения #20. Отмена: полный пример

В прошлом посте разобрали механику прерывания по частям. Теперь соберём рабочее приложение и посмотрим, что где объявляется.

Правило распределения такое: токен создаёт инфраструктура, проверяет его рабочий код, а обрабатывает прерывание команда. Сервис ничего не знает ни про консоль, ни про коды возврата — он только бросает исключение. Команда не знает, как устроена работа — она ловит исключение и убирает за собой.

Ctrl+C → System.CommandLine → SetAction → сервис → catch/finally/130

Схема передачи токена отмены

Разложим по файлам. 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 превратится в копирование, и смысл приёма потеряется.

Подход рабочий и для чего угодно другого: отмена проходит сквозь все слои, а уборка живёт там же, где создаются ресурсы.

Дальше займёмся настройками: где хранить конфиг, как его искать и в каком порядке применять источники.