question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

CLI Command RunAsync support passing in/propagating a CancelationToken

See original GitHub issue

Is your feature request related to a problem? Please describe. By convention in DotNet Core one wild supply a CancellationToken into an Async method when awaited!

Describe the solution you’d like Support passing in a CancelationToken such that is propagated to the AsyncCommand.ExecuteAsync and for that matter provide a ValidateAsync that also takes the token.

In this way a command handler can detect it’s being canceled (via async Main for example)

Describe alternatives you’ve considered

One can make the organ available via static and class scoped but its not very well encapsulated.

Additional context N/A

Issue Analytics

  • State:open
  • Created 2 years ago
  • Reactions:4
  • Comments:10 (3 by maintainers)

github_iconTop GitHub Comments

9reactions
Cryptoc1commented, Feb 25, 2022

I recently encountered the need to support cancellation in my CLI, and wanted to share my solution/workaround.

In my case, SIGINT/SIGTERM would terminate the cli process, but some background work would continue running. The API managing this background work supported passing a CancellationToken, so my solution consist of a abstract implementation of AsyncCommand that uses a CancellationTokenSource to encapsulate the System.Console.CancelKeyPress and AppDomain.CurrentDomain.ProcessExit events:

using Spectre.Console.Cli;

namespace Cli;

public abstract class CancellableAsyncCommand : AsyncCommand
{
    public abstract Task<int> ExecuteAsync( CommandContext context, CancellationToken cancellation );

    public sealed override async Task<int> ExecuteAsync( CommandContext context )
    {
        using var cancellationSource = new CancellationTokenSource();

        System.Console.CancelKeyPress += onCancelKeyPress;
        AppDomain.CurrentDomain.ProcessExit += onProcessExit;

        using var _ = cancellationSource.Token.Register(
            ( ) =>
            {
                AppDomain.CurrentDomain.ProcessExit -= onProcessExit;
                System.Console.CancelKeyPress -= onCancelKeyPress;
            }
        );

        var cancellable = ExecuteAsync( context, cancellationSource.Token );
        return await cancellable;

        void onCancelKeyPress( object? sender, ConsoleCancelEventArgs e )
        {
            // NOTE: cancel event, don't terminate the process
            e.Cancel = true;

            cancellationSource.Cancel();
        }

        void onProcessExit( object? sender, EventArgs e )
        {
            if( cancellationSource.IsCancellationRequested )
            {
                // NOTE: SIGINT (cancel key was pressed, this shouldn't ever actually hit however, as we remove the event handler upon cancellation of the `cancellationSource`)
                return;
            }

            cancellationSource.Cancel();
        }
    }
}

When targeting NETCOREAPP directly a PosixSignalRegistration, as @Simonl9l suggests, makes for a simpler implementation:

using System.Runtime.InteropServices;
using Spectre.Console.Cli;

namespace Cli;

public abstract class CancellableAsyncCommand : AsyncCommand
{
    public abstract Task<int> ExecuteAsync( CommandContext context, CancellationToken cancellation );

    public sealed override async Task<int> ExecuteAsync( CommandContext context )
    {
        using var cancellationSource = new CancellationTokenSource();

        using var sigInt = PosixSignalRegistration.Create( PosixSignal.SIGINT, onSignal );
        using var sigQuit = PosixSignalRegistration.Create( PosixSignal.SIGQUIT, onSignal );
        using var sigTerm = PosixSignalRegistration.Create( PosixSignal.SIGTERM, onSignal );

        var cancellable = ExecuteAsync( context, cancellationSource.Token );
        return await cancellable;

        void onSignal( PosixSignalContext context )
        {
            context.Cancel = true;
            cancellationSource.Cancel();
        }
    }
}

With this approach, commands inherit from CancellableAsyncCommand, rather than AsyncCommand, and no changes should be needed to the Program.cs:

using Microsoft.Extensions.DependencyInjection;
using Spectre.Console.Cli;

var services = new ServiceCollection()
    .AddCliServices();

var registrar = new TypeRegistrar( services );
var app = new CommandApp<DefaultCommand>( registrar );

app.Configure(
    config =>
    {
#if DEBUG
        config.PropagateExceptions();
        config.ValidateExamples();
#endif
    }
);

return await app.RunAsync( args );

public class DefaultCommand : CancellableAsyncCommand
{
    public async Task<int> ExecuteAsync( CommandContext context, CancellationToken cancellation )
    {
        await DoWorkInThreads( cancellation );

        // ...
        return 0;
    } 
}

References:

P.S. I’ve just started using Spectre after using natemcmaster/CommandLineUtils for a while, and playing around with dotnet/command-line-api a bit; I think I’m in love… ✌🏻❤️

8reactions
alvpickmanscommented, Mar 29, 2022

@Cryptoc1 nice workaround, it feels it would be a nice and simple addition to the library (yet I’m sure it has some implications)

I’ve decouple the cancellation token source from the abstract command implementation so it can be used on both AsyncCommand and AsyncCommand<TSettings>, but otherwise pretty nice solution for now!

internal sealed class ConsoleAppCancellationTokenSource 
{
    private readonly CancellationTokenSource _cts = new();

    public CancellationToken Token => _cts.Token;

    public ConsoleAppCancellationTokenSource()
    {
        System.Console.CancelKeyPress += OnCancelKeyPress;
        AppDomain.CurrentDomain.ProcessExit += OnProcessExit;

        using var _ = _cts.Token.Register(
            () =>
            {
                AppDomain.CurrentDomain.ProcessExit -= OnProcessExit;
                System.Console.CancelKeyPress -= OnCancelKeyPress;
            }
        );
    }

    private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
    {
        // NOTE: cancel event, don't terminate the process
        e.Cancel = true;

        _cts.Cancel();
    }

    private void OnProcessExit(object? sender, EventArgs e)
    {
        if (_cts.IsCancellationRequested)
        {
            // NOTE: SIGINT (cancel key was pressed, this shouldn't ever actually hit however, as we remove the event handler upon cancellation of the `cancellationSource`)
            return;
        }

        _cts.Cancel();
    }
}
public abstract class CancellableAsyncCommand : AsyncCommand
{
    private readonly ConsoleAppCancellationTokenSource _cancellationTokenSource = new();

    public abstract Task<int> ExecuteAsync(CommandContext context, CancellationToken cancellation);

    public sealed override async Task<int> ExecuteAsync(CommandContext context)
        => await ExecuteAsync(context, _cancellationTokenSource.Token);

}

public abstract class CancellableAsyncCommand<TSettings> : AsyncCommand<TSettings>
    where TSettings : CommandSettings
{
    private readonly ConsoleAppCancellationTokenSource _cancellationTokenSource = new();

    public abstract Task<int> ExecuteAsync(CommandContext context, TSettings settings, CancellationToken cancellation);

    public sealed override async Task<int> ExecuteAsync(CommandContext context, TSettings settings)
        => await ExecuteAsync(context, settings, _cancellationTokenSource.Token);
}
Read more comments on GitHub >

github_iconTop Results From Across the Web

StatelessService.RunAsync(CancellationToken) Method
Make sure cancellationToken passed to RunAsync(CancellationToken) is honored and once it has been signaled, RunAsync(CancellationToken) exits gracefully as soon ...
Read more >
Untitled
Web26 Nov 2019 · In fact, if you pass a cancellationToken to ... the girl songs CLI Command RunAsync support passing in/propagating …...
Read more >
c# - CancellationToken doesn't propagate
Run(async () => await action(cancellationToken.Token, observation)); you are scheduling a Task that will start another task which is action ...
Read more >
Temporalio 0.1.0-beta1
This can accept a cancellation token, but if none given, defaults to Workflow.CancellationToken . Task.Delay or any other .NET timer-related call cannot be...
Read more >
Developing with asyncio — Python 3.11.4 documentation
Passing debug=True to asyncio.run() . ... One way of doing that is by using the -W default command line option. When the debug...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found