mirror of
https://github.com/Tyrrrz/CliFx.git
synced 2025-10-25 15:19:17 +00:00
Refactor cancellation (#30)
This commit is contained in:
@@ -17,6 +17,6 @@ namespace CliFx.Benchmarks.Commands
|
||||
[CommandOption("bool", 'b')]
|
||||
public bool BoolOption { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ namespace CliFx.Demo.Commands
|
||||
_libraryService = libraryService;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
// To make the demo simpler, we will just generate random publish date and ISBN if they were not set
|
||||
if (Published == default)
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace CliFx.Demo.Commands
|
||||
_libraryService = libraryService;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
var book = _libraryService.GetBook(Title);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace CliFx.Demo.Commands
|
||||
_libraryService = libraryService;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
var library = _libraryService.GetLibrary();
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace CliFx.Demo.Commands
|
||||
_libraryService = libraryService;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
var book = _libraryService.GetBook(Title);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using NUnit.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Services;
|
||||
using CliFx.Tests.Stubs;
|
||||
@@ -237,8 +238,9 @@ namespace CliFx.Tests
|
||||
{
|
||||
// Arrange
|
||||
using (var stdoutStream = new StringWriter())
|
||||
using (var cancellationTokenSource = new CancellationTokenSource())
|
||||
{
|
||||
var console = new VirtualConsole(stdoutStream);
|
||||
var console = new VirtualConsole(stdoutStream, cancellationTokenSource.Token);
|
||||
|
||||
var application = new CliApplicationBuilder()
|
||||
.AddCommand(typeof(CancellableCommand))
|
||||
@@ -248,7 +250,7 @@ namespace CliFx.Tests
|
||||
|
||||
// Act
|
||||
var runTask = application.RunAsync(args);
|
||||
console.Cancel();
|
||||
cancellationTokenSource.Cancel();
|
||||
var exitCode = await runTask.ConfigureAwait(false);
|
||||
var stdOut = stdoutStream.ToString().Trim();
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
@@ -9,13 +8,13 @@ namespace CliFx.Tests.TestCommands
|
||||
[Command("cancel")]
|
||||
public class CancellableCommand : ICommand
|
||||
{
|
||||
public async Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
console.Output.WriteLine("Printed");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false);
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), console.GetCancellationToken()).ConfigureAwait(false);
|
||||
|
||||
console.Output.WriteLine("Never printed");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("msg", 'm')]
|
||||
public string Message { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => throw new CommandException(Message, ExitCode);
|
||||
public Task ExecuteAsync(IConsole console) => throw new CommandException(Message, ExitCode);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption('s', Description = "String separator.")]
|
||||
public string Separator { get; set; } = "";
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.Output.WriteLine(string.Join(Separator, Inputs));
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace CliFx.Tests.TestCommands
|
||||
// This property should be ignored by resolver
|
||||
public bool NotAnOption { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.Output.WriteLine(Dividend / Divisor);
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -14,6 +14,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("fruits")]
|
||||
public string Oranges { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption('f')]
|
||||
public string Oranges { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("opt", EnvironmentVariableName = "ENV_SINGLE_VALUE")]
|
||||
public string Option { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("opt", EnvironmentVariableName = "ENV_MULTIPLE_VALUES")]
|
||||
public IEnumerable<string> Option { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("opt", EnvironmentVariableName = "ENV_MULTIPLE_VALUES")]
|
||||
public string Option { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("msg", 'm')]
|
||||
public string Message { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => throw new Exception(Message);
|
||||
public Task ExecuteAsync(IConsole console) => throw new Exception(Message);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace CliFx.Tests.TestCommands
|
||||
[Command]
|
||||
public class HelloWorldDefaultCommand : ICommand
|
||||
{
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.Output.WriteLine("Hello world.");
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -14,6 +14,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("option-b", 'b', Description = "OptionB description.")]
|
||||
public string OptionB { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("option-d", 'd', Description = "OptionD description.")]
|
||||
public string OptionD { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,6 @@ namespace CliFx.Tests.TestCommands
|
||||
[CommandOption("option-e", 'e', Description = "OptionE description.")]
|
||||
public string OptionE { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ namespace CliFx.Tests.TestCommands
|
||||
{
|
||||
public class NonAnnotatedCommand : ICommand
|
||||
{
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task ExecuteAsync(IConsole console) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ namespace CliFx
|
||||
_commandInitializer.InitializeCommand(command, targetCommandSchema, commandInput);
|
||||
|
||||
// Execute command
|
||||
await command.ExecuteAsync(_console, _console.CancellationToken);
|
||||
await command.ExecuteAsync(_console);
|
||||
|
||||
// Finish the chain with exit code 0
|
||||
return 0;
|
||||
|
||||
@@ -13,6 +13,6 @@ namespace CliFx
|
||||
/// Executes command using specified implementation of <see cref="IConsole"/>.
|
||||
/// This method is called when the command is invoked by a user through command line interface.
|
||||
/// </summary>
|
||||
Task ExecuteAsync(IConsole console, CancellationToken cancellationToken);
|
||||
Task ExecuteAsync(IConsole console);
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,9 @@ namespace CliFx.Services
|
||||
void ResetColor();
|
||||
|
||||
/// <summary>
|
||||
/// Cancels when soft cancellation requested.
|
||||
/// Provides token that cancels when application cancellation is requested.
|
||||
/// Subsequent calls return the same token.
|
||||
/// </summary>
|
||||
CancellationToken CancellationToken { get; }
|
||||
CancellationToken GetCancellationToken();
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,7 @@ namespace CliFx.Services
|
||||
/// </summary>
|
||||
public class SystemConsole : IConsole
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SystemConsole()
|
||||
{
|
||||
// Subscribe to CancelKeyPress event with cancellation token source
|
||||
// Kills app on second cancellation (hard cancellation)
|
||||
Console.CancelKeyPress += (_, args) =>
|
||||
{
|
||||
if (_cancellationTokenSource.IsCancellationRequested)
|
||||
return;
|
||||
args.Cancel = true;
|
||||
_cancellationTokenSource.Cancel();
|
||||
};
|
||||
}
|
||||
private CancellationTokenSource _cancellationTokenSource;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TextReader Input => Console.In;
|
||||
@@ -61,6 +47,24 @@ namespace CliFx.Services
|
||||
public void ResetColor() => Console.ResetColor();
|
||||
|
||||
/// <inheritdoc />
|
||||
public CancellationToken CancellationToken => _cancellationTokenSource.Token;
|
||||
public CancellationToken GetCancellationToken()
|
||||
{
|
||||
if (_cancellationTokenSource is null)
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
// Subscribe to CancelKeyPress event with cancellation token source
|
||||
// Kills app on second cancellation (hard cancellation)
|
||||
Console.CancelKeyPress += (_, args) =>
|
||||
{
|
||||
if (_cancellationTokenSource.IsCancellationRequested)
|
||||
return;
|
||||
args.Cancel = true;
|
||||
_cancellationTokenSource.Cancel();
|
||||
};
|
||||
}
|
||||
|
||||
return _cancellationTokenSource.Token;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace CliFx.Services
|
||||
/// </summary>
|
||||
public class VirtualConsole : IConsole
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public TextReader Input { get; }
|
||||
@@ -43,7 +43,8 @@ namespace CliFx.Services
|
||||
/// </summary>
|
||||
public VirtualConsole(TextReader input, bool isInputRedirected,
|
||||
TextWriter output, bool isOutputRedirected,
|
||||
TextWriter error, bool isErrorRedirected)
|
||||
TextWriter error, bool isErrorRedirected,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Input = input.GuardNotNull(nameof(input));
|
||||
IsInputRedirected = isInputRedirected;
|
||||
@@ -51,13 +52,15 @@ namespace CliFx.Services
|
||||
IsOutputRedirected = isOutputRedirected;
|
||||
Error = error.GuardNotNull(nameof(error));
|
||||
IsErrorRedirected = isErrorRedirected;
|
||||
_cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes an instance of <see cref="VirtualConsole"/>.
|
||||
/// </summary>
|
||||
public VirtualConsole(TextReader input, TextWriter output, TextWriter error)
|
||||
: this(input, true, output, true, error, true)
|
||||
public VirtualConsole(TextReader input, TextWriter output, TextWriter error,
|
||||
CancellationToken cancellationToken = default)
|
||||
: this(input, true, output, true, error, true, cancellationToken)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -65,8 +68,8 @@ namespace CliFx.Services
|
||||
/// Initializes an instance of <see cref="VirtualConsole"/> using output stream (stdout) and error stream (stderr).
|
||||
/// Input stream (stdin) is replaced with a no-op stub.
|
||||
/// </summary>
|
||||
public VirtualConsole(TextWriter output, TextWriter error)
|
||||
: this(TextReader.Null, output, error)
|
||||
public VirtualConsole(TextWriter output, TextWriter error, CancellationToken cancellationToken = default)
|
||||
: this(TextReader.Null, output, error, cancellationToken)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -74,8 +77,8 @@ namespace CliFx.Services
|
||||
/// Initializes an instance of <see cref="VirtualConsole"/> using output stream (stdout).
|
||||
/// Input stream (stdin) and error stream (stderr) are replaced with no-op stubs.
|
||||
/// </summary>
|
||||
public VirtualConsole(TextWriter output)
|
||||
: this(output, TextWriter.Null)
|
||||
public VirtualConsole(TextWriter output, CancellationToken cancellationToken = default)
|
||||
: this(output, TextWriter.Null, cancellationToken)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -87,14 +90,6 @@ namespace CliFx.Services
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CancellationToken CancellationToken => _cancellationTokenSource.Token;
|
||||
|
||||
/// <summary>
|
||||
/// Simulates cancellation.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
_cancellationTokenSource.Cancel();
|
||||
}
|
||||
public CancellationToken GetCancellationToken() => _cancellationToken;
|
||||
}
|
||||
}
|
||||
40
Readme.md
40
Readme.md
@@ -24,6 +24,7 @@ _CliFx is to command line interfaces what ASP.NET Core is to web applications._
|
||||
- Resolves commands and options using attributes
|
||||
- Handles options of various types, including custom types
|
||||
- Supports multi-level command hierarchies
|
||||
- Allows cancellation
|
||||
- Generates contextual help text
|
||||
- Prints errors and routes exit codes on exceptions
|
||||
- Highly testable and easy to debug
|
||||
@@ -87,7 +88,7 @@ public class LogCommand : ICommand
|
||||
[CommandOption("base", 'b', Description = "Logarithm base.")]
|
||||
public double Base { get; set; } = 10;
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
var result = Math.Log(Value, Base);
|
||||
console.Output.WriteLine(result);
|
||||
@@ -99,10 +100,7 @@ public class LogCommand : ICommand
|
||||
|
||||
By implementing `ICommand` this class also provides `ExecuteAsync` method. This is the method that gets called when the user invokes the command. Its return type is `Task` in order to facilitate asynchronous execution, but if your command runs synchronously you can simply return `Task.CompletedTask`.
|
||||
|
||||
This method takes two parameters: an instance of `IConsole` and `CancellationToken`.
|
||||
|
||||
You should use the `console` parameter in places where you would normally use `System.Console`, in order to make your command testable.
|
||||
The `cancellationToken` parameter can be used to handle interrupt signal (Ctrl+C or Ctrl+Break) to gracefully cancel execution and perform any necessary cleanup. If another interrupt signal is received after the first one, the application will terminate immediately.
|
||||
The `ExecuteAsync` method also takes an instance of `IConsole` as a parameter. You should use the `console` parameter in places where you would normally use `System.Console`, in order to make your command testable.
|
||||
|
||||
Finally, the command defined above can be executed from the command line in one of the following ways:
|
||||
|
||||
@@ -176,7 +174,7 @@ public class DivideCommand : ICommand
|
||||
[CommandOption("divisor", IsRequired = true)]
|
||||
public double Divisor { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
if (Math.Abs(Divisor) < double.Epsilon)
|
||||
{
|
||||
@@ -218,6 +216,30 @@ public class SecondSubCommand : ICommand
|
||||
}
|
||||
```
|
||||
|
||||
### Cancellation
|
||||
|
||||
It is possible to gracefully cancel execution of a command and preform any necessary cleanup. By default an app gets forcefully killed when it receives an interrupt signal (Ctrl+C or Ctrl+Break). You can call `console.GetCancellationToken()` to override the default behavior and get `CancellationToken` that represents the first interrupt signal. Second interrupt signal terminates an app immediately. Note that the code that executes before the first call to `GetCancellationToken` will not be cancellation aware.
|
||||
|
||||
You can pass `CancellationToken` around and check its state.
|
||||
|
||||
Cancelled or terminated app returns non-zero exit code.
|
||||
|
||||
```c#
|
||||
[Command("cancel")]
|
||||
public class CancellableCommand : ICommand
|
||||
{
|
||||
public async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.Output.WriteLine("Printed");
|
||||
|
||||
// Long-running cancellable operation that throws when canceled
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, console.GetCancellationToken());
|
||||
|
||||
console.Output.WriteLine("Never printed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency injection
|
||||
|
||||
CliFx uses an implementation of `ICommandFactory` to initialize commands and by default it only works with types that have parameterless constructors.
|
||||
@@ -266,7 +288,7 @@ public class UserAddCommand : ICommand
|
||||
[CommandOption("email", 'e')]
|
||||
public string Email { get; set; }
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
var validationResult = new UserAddCommandValidator().Validate(this);
|
||||
if (!validationResult.IsValid)
|
||||
@@ -339,7 +361,7 @@ public class ConcatCommand : ICommand
|
||||
[CommandOption("right")]
|
||||
public string Right { get; set; } = "world";
|
||||
|
||||
public Task ExecuteAsync(IConsole console, CancellationToken cancellationToken)
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.Output.Write(Left);
|
||||
console.Output.Write(' ');
|
||||
@@ -368,7 +390,7 @@ public async Task ConcatCommand_Test()
|
||||
};
|
||||
|
||||
// Act
|
||||
await command.ExecuteAsync(console, CancellationToken.None);
|
||||
await command.ExecuteAsync(console);
|
||||
|
||||
// Assert
|
||||
Assert.That(stdout.ToString(), Is.EqualTo("foo bar"));
|
||||
|
||||
Reference in New Issue
Block a user