dotnet / command-line-api

Command line parsing, invocation, and rendering of terminal output.
https://github.com/dotnet/command-line-api/wiki
MIT License
3.38k stars 380 forks source link

correct way to handle Ctrl+C ? #2430

Closed sanghel-orbyta closed 4 months ago

sanghel-orbyta commented 4 months ago

Hi.

I'm having issues with handling of user termination with current nuget packages:

.NET 8

System.CommandLine 2.0.0-beta4.22272.1
System.CommandLine.Hosting 0.4.0-alpha.22272.1

Ctrl+C doesn't terminate async handler invocations. I believe I'm correctly propagating the cancellation token, but I guess I'm missing something.

Program.cs

private static async Task<int> Main(string[] args)
    {
        var parser = RegisterCommands()
            .UseHost(_ => Host.CreateDefaultBuilder(args),
                builder => {
                    builder
                        .ConfigureServices((_, services) => {})
                        .UseCommandHandler<MyCommand, MyCommand.Handler>();
                })
            .UseDefaults()
            .Build();

        return await parser.InvokeAsync(args);
    }

    private static CommandLineBuilder RegisterCommands()
    {
        var rootCommand = new RootCommand();
        rootCommand.AddCommand(new MyCommand());
        return new CommandLineBuilder(rootCommand);
    }

MyCommand.cs

public class MyCommand : Command
{
    public MyCommand()
        : base(name: "command", "sample command")
    {
        ConfigureArguments();
        ConfigureOptions();
    }

    private void ConfigureArguments()
    {
        var sourceFileArg = new Argument<FileInfo>(name: "sourceFile", description: "File to use");
        sourceFileArg.AddValidator(result => {
            var parsedValue = result.GetValueForArgument(sourceFileArg);
            if (!parsedValue.Exists)
            {
                result.ErrorMessage = $"File provided for '{sourceFileArg.Name}' not found at {parsedValue.FullName}";
            }
        });
        AddArgument(sourceFileArg);
    }

    /// <summary>
    ///     Options are bound to handler properties by convention, from the longest alias name
    /// </summary>
    private void ConfigureOptions()
    {
        var stringOption = new Option<string?>(aliases: ["-o", "--option-string"], description: "A string option") {
            IsRequired = false
        };
        stringOption.SetDefaultValue(Defaults.StringOption);
        AddOption(stringOption);
    }

    private static class Defaults
    {
        public const string StringOption = "mystring";
    }

    /// <summary>
    ///     Handler public properties are bound by convention with argument and option names
    /// </summary>
    public new class Handler(ILogger<Handler> logger) : ICommandHandler
    {
        public required string OptionString { get; set; }

        public required FileInfo SourceFile { get; set; }

        public int Invoke(InvocationContext context)
        {
            throw new NotImplementedException();
        }

        public async Task<int> InvokeAsync(InvocationContext context)
        {
            logger.LogInformation("beginning streaming..");

            try
            {
                var token = context.GetCancellationToken();
                while (!token.IsCancellationRequested)
                {
                    await BeginStreamingFile(token);
                    return 0;
                }
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("User cancelled");
                return -1;
            }

            return -1;
        }

        private async Task BeginStreamingFile(CancellationToken ct)
        {
            await using var sourceFileStream = new FileStream(SourceFile.FullName,
                new FileStreamOptions {
                    Access = FileAccess.Read,
                    BufferSize = 0,
                    Mode = FileMode.Open,
                    Share = FileShare.Read,
                    Options = FileOptions.RandomAccess
                });
            await using var streamer = new SomeStreamerInstance();
            streamer.OnProgress += (_, args) => {
                // show progress to console
            };

            await streamer.Start(sourceFileStream, ct);
        }
    }
}

Any help is much appreciated.

Edit:

I've already seen that CommandLineBuilder.UseDefaults() also calls .CancelOnProcessTermination() that performs some event callback registration for ConsoleCancelEventHandler.

I just have no idea how exactly am I supposed to "correctly" write an ICommandler.InvokeAsync.

sanghel-orbyta commented 4 months ago

..it was PEBKAC... in the "streamer" instance I am performing some looped sync operation that was not checking for the cancellation of passed token