Skip to content

Commit

Permalink
Add cancellation token support (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
neoscie authored Jan 23, 2025
1 parent 32905f4 commit 4ec29fe
Show file tree
Hide file tree
Showing 13 changed files with 83 additions and 19 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added cancellation token support for commands
- Added a built-in `verify-dependencies` command that validates the application's Dependency Injection setup by ensuring all required services are fully and correctly registered in the composition root.

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public EchoCommand(ILogger<EchoCommand> logger, IHostEnvironment environment, IC
}

/// <inheritdoc />
public Task RunAsync(EchoOptions options)
public Task RunAsync(EchoOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public GuidGenCommand(ILogger<GuidGenCommand> logger)
}

/// <inheritdoc />
public Task RunAsync(GuidGenOptions options)
public Task RunAsync(GuidGenOptions options, CancellationToken cancellationToken)
{
if (options == null)
{
Expand All @@ -42,6 +42,7 @@ public Task RunAsync(GuidGenOptions options)
}

System.Console.WriteLine(result);

this.logger.LogTrace("Wrote result to console");
return Task.CompletedTask;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ public StartCommand(ILogger<StartCommand> logger, IHttpClientFactory httpClientF
}

/// <inheritdoc />
public async Task RunAsync(StartOptions options)
public async Task RunAsync(StartOptions options, CancellationToken cancellationToken)
{
this.logger.LogDebug("Check if Google is online");
var response = await this.httpClient.GetAsync(new Uri("https://www.google.com"));
var response = await this.httpClient.GetAsync(new Uri("https://www.google.com"), cancellationToken);
if (response.IsSuccessStatusCode)
{
this.logger.LogTrace("Internet connection is available");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
/// </summary>
public class DotNetConsoleBuilderTests
{
/// <summary>
/// The argument string for the internal verify-dependencies command
/// </summary>
private static readonly string[] VerifyDependenciesArgs = { "verify-dependencies" };

/// <summary>
/// Given a mistyped verb, when a default verb is defined, then should throw on console building.
/// </summary>
Expand Down Expand Up @@ -98,7 +103,7 @@ public void GivenInvalidArguments_WhenNoDefaultVerbIsDefined_ThenShouldNotThrowO
public void GivenVerifyDependenciesCommand_WhenRegistrationIsMissing_ThenShouldThrow()
{
// Arrange
var builder = DotNetConsole.CreateBuilderWithReference(Assembly.GetAssembly(typeof(DefaultCommand))!, new[] { "verify-dependencies" });
var builder = DotNetConsole.CreateBuilderWithReference(Assembly.GetAssembly(typeof(DefaultCommand))!, VerifyDependenciesArgs);

// Intentionally only registering the transient service and not the scoped and singleton services.
builder.Services.AddTransient<ITransientServiceStub, TransientServiceStub>();
Expand Down
5 changes: 1 addition & 4 deletions Neolution.DotNet.Console.UnitTests/DotNetConsoleRunTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,7 @@ public void GivenServicesWithVariousServiceLifetimes_WhenRunningConsoleApp_ThenS
[InlineData("verify-dependencies")]
public void GivenValidArguments_WhenNoDefaultVerbIsDefined_ThenShouldNotThrow([NotNull] string args)
{
if (args == null)
{
throw new ArgumentNullException(nameof(args));
}
ArgumentNullException.ThrowIfNull(args);

// Arrange
var builder = DotNetConsole.CreateBuilderWithReference(Assembly.GetAssembly(typeof(DefaultCommand))!, args.Split(" "));
Expand Down
3 changes: 2 additions & 1 deletion Neolution.DotNet.Console.UnitTests/Fakes/DefaultCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Neolution.DotNet.Console.UnitTests.Fakes
{
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Neolution.DotNet.Console.Abstractions;
Expand Down Expand Up @@ -33,7 +34,7 @@ public DefaultCommand(IUnitTestLogger logger, IHostEnvironment environment)
}

/// <inheritdoc />
public async Task RunAsync(DefaultOptions options)
public async Task RunAsync(DefaultOptions options, CancellationToken cancellationToken)
{
this.logger.Log("options", options);
this.logger.Log("environment", this.environment);
Expand Down
3 changes: 2 additions & 1 deletion Neolution.DotNet.Console.UnitTests/Fakes/EchoCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Neolution.DotNet.Console.UnitTests.Fakes
{
using System.Threading;
using System.Threading.Tasks;
using Neolution.DotNet.Console.Abstractions;
using Neolution.DotNet.Console.UnitTests.Spies;
Expand All @@ -25,7 +26,7 @@ public EchoCommand(IUnitTestLogger logger)
}

/// <inheritdoc />
public async Task RunAsync(EchoOptions options)
public async Task RunAsync(EchoOptions options, CancellationToken cancellationToken)
{
this.logger.Log("options", options);
await Task.CompletedTask;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Neolution.CodeAnalysis.TestsRuleset" Version="3.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Neolution.DotNet.Console.UnitTests.Stubs
{
using System.Threading;
using System.Threading.Tasks;
using Neolution.DotNet.Console.Abstractions;

Expand Down Expand Up @@ -38,7 +39,7 @@ public InjectServicesWithVariousLifetimesCommandStub(ITransientServiceStub trans
}

/// <inheritdoc />
public async Task RunAsync(InjectServicesWithVariousLifetimesOptionsStub options)
public async Task RunAsync(InjectServicesWithVariousLifetimesOptionsStub options, CancellationToken cancellationToken)
{
await this.transientService.DoSomethingAsync();
await this.scopedService.DoSomethingAsync();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace Neolution.DotNet.Console.Abstractions
{
using System.Threading;
using System.Threading.Tasks;

/// <summary>
Expand All @@ -12,7 +13,8 @@ public interface IDotNetConsoleCommand<in TOptions>
/// Runs the command with the specified options asynchronously.
/// </summary>
/// <param name="options">The options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The <see cref="Task"/>.</returns>
Task RunAsync(TOptions options);
Task RunAsync(TOptions options, CancellationToken cancellationToken);
}
}
61 changes: 56 additions & 5 deletions Neolution.DotNet.Console/DotNetConsole.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
namespace Neolution.DotNet.Console
{
using System;
using System.Globalization;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using CommandLine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Neolution.DotNet.Console.Abstractions;
using NLog;
using NLog.Extensions.Logging;

/// <summary>
/// The console application.
Expand Down Expand Up @@ -82,16 +87,55 @@ public static DotNetConsoleBuilder CreateBuilderWithReference(Assembly servicesA
/// <inheritdoc />
public async Task RunAsync()
{
await this.commandLineParserResult.WithParsedAsync(this.RunWithOptionsAsync);
using var cancellationTokenSource = new CancellationTokenSource();

Console.CancelKeyPress += (_, e) =>
{
// Prevent the console from terminating immediately and instead cancel the cancellation token source.
e.Cancel = true;
cancellationTokenSource.Cancel();
};

await this.RunAsync(cancellationTokenSource.Token);
}

/// <summary>
/// Runs the application.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The <see cref="Task"/>.</returns>
public async Task RunAsync(CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
await this.commandLineParserResult.WithParsedAsync(async options => await this.RunWithOptionsAsync(options, cancellationToken));
}
catch (OperationCanceledException)
{
try
{
var logger = LogManager.Setup().LoadConfigurationFromSection(this.Services.GetRequiredService<IConfiguration>()).GetCurrentClassLogger();
logger.Log(LogLevel.Info, CultureInfo.InvariantCulture, message: "Operation was canceled by the user.");
}
catch (Exception)
{
// Ignore any exceptions that might occur while trying to log the cancellation
}
}
}

/// <summary>
/// Runs the command with options asynchronously.
/// </summary>
/// <param name="options">The options.</param>
/// <returns>The <see cref="Task"/>.</returns>
private async Task RunWithOptionsAsync(object options)
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns> The <see cref="Task" />. </returns>
private async Task RunWithOptionsAsync(object options, CancellationToken cancellationToken)
{
// Check for cancellation before proceeding
cancellationToken.ThrowIfCancellationRequested();

// To support scoped services, create a scope for each command call/run.
var scopeFactory = this.Services.GetRequiredService<IServiceScopeFactory>();
await using var scope = scopeFactory.CreateAsyncScope();
Expand All @@ -104,9 +148,16 @@ private async Task RunWithOptionsAsync(object options)

// Resolve the command from the service provider by the determined type and invoke the entry point method (RunAsync)
var command = scope.ServiceProvider.GetRequiredService(commandType);
if (commandEntryPointMethodInfo?.Invoke(command, new[] { options }) is Task commandRunTask)
if (commandEntryPointMethodInfo != null)
{
await commandRunTask;
// Prepare the parameters, including the cancellation token
var parameters = new[] { options, cancellationToken };

// Invoke the command's RunAsync method with the cancellation token
if (commandEntryPointMethodInfo.Invoke(command, parameters) is Task commandRunTask)
{
await commandRunTask;
}
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ To help you kickstart your console application, we've provided a a [sample appli

# Guides

## Migrate from V3 to V4

To support cancellation tokens, the `IDotNetConsoleCommand` interface had to be changed: The `RunAsync` method now requires also a `CancellationToken` as a parameter. This change is breaking, so you will need to update your commands to reflect this change.

## Migrate from V2 to V3

In .NET 6 the hosting model for ASP.NET Core applications was changed, we adjusted to that to fulfill the primary goal of this package: to provide a seamless and intuitive user experience. This introduces breaking changes that are explained below.
Expand Down

0 comments on commit 4ec29fe

Please sign in to comment.