A Beginner’s Guide to .NET’s HostBuilder: Part 3 — Cancellation
This part covers cancellation features built into .NET’s HostBuilder
(and how to rebuild them when writing a console app). Here is the console app’s current working structure:
$ tree -I 'obj|bin' # This will print the tree structure, ignore obj/ and bin/
.
├── appsettings.Development.json
├── appsettings.json
├── Driver.cs
├── HostBuilderDemo.csproj
├── Program.cs
└── Properties
└── launchSettings.json
1 directory, 6 files
The series
- Introduction and Setup
- Dependency injection
- Cancellation
- Configurations and Environments
- Logging and Serilog
Table of contents
Readers may need to refresh after clicking a section
A running host’s cancellation feature
When running the host normally, such as through the worker
or webapi
templates, the host will automatically handle the interrupt
signals sent to the app (usually via ctl-c
from the terminal). The host will then relay this signal to the app through a CancellationToken
parameter, telling the app to gracefully shut down. If the app does not gracefully shut itself down within five seconds, the host will force-close the app. Alternatively, if the interrupt
signal is sent again, then the host will forgo the remaining timeout and immediately force-close the app.
When working with the worker
template, the auto-generated Worker
class will be created with the CancellationToken
parameter on its ExecuteAsync
method.
When making a web API, the CancellationToken
is also used to communicate when the client cancels their web request. However, the parameter is not demonstrated in the webapi
template; as such, this parameter is demonstrated at the end of the article.
Adding and using cancellation support to the console app
Recall that this console application only builds the host, it does not run the host. As such, the app will need to recreate the interrupt
/ CancellationToken
functionality that is normally provided by the running host. To reiterate, when working with the worker
and webapi
templates directly, this functionality is supplied out-of-the-box. Here is the new Program.cs
:
using HostBuilderDemo;
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddTransient<Driver>();
})
.Build();
// Added this paragraph:
var source = new CancellationTokenSource();
bool graceful = true;
Console.CancelKeyPress += new ConsoleCancelEventHandler((_, cancelEvent) => {
if (graceful)
{
// If you're using a logger package (like Serilog), use that
// singleton instead.
Console.WriteLine("Received interrupt signal, attempting to shut down gracefully but will force-close in 5 seconds. Send again to immediately force-close");
source.Cancel();
cancelEvent.Cancel = true;
graceful = false;
new Timer(
new TimerCallback(_ =>
{
// If you're using a logger package (like Serilog), use
// that singleton instead.
Console.WriteLine("Timeout reached, force-closing app");
Environment.Exit(0);
}),
state: null,
dueTime: 5000,
period: 0);
}
else
{
// If you're using a logger package (like Serilog), use that
// singleton instead.
Console.WriteLine("Second interrupt received, force-closing the app");
}
});
await host.Services.GetRequiredService<Driver>().RunAsync(source.Token);
To see the app gracefully shutdown, update Driver.cs
to the following code, then run the app from the command line, and then, once a few prints have occurred, send the interrupt
signal via ctl-c
.
Driver.cs
:
namespace HostBuilderDemo;
public class Driver
{
public Task RunAsync(
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
Console.WriteLine($"Hello, internet!");
Thread.Sleep(1000);
}
Console.WriteLine("bye bye!");
return Task.CompletedTask;
}
}
Output
:
$ dotnet run
Building...
Hello, internet!
Hello, internet!
Hello, internet!
Hello, internet!
^CReceived interrupt signal, attempting to shut down gracefully but will force-close in 5 seconds. Send again to immediately force-close
bye bye!
To see the app forcefully shut down on a second interrupt, update Driver.cs
to the following code, then run the app from the command line, and then, once a few prints have occurred, send the interrupt
signal twice.
Driver.cs
:
namespace HostBuilderDemo;
public class Driver
{
public Task RunAsync(
CancellationToken cancellationToken)
{
while (true)
{
Console.WriteLine($"Hello, internet!");
Thread.Sleep(1000);
}
}
}
Output
:
$ dotnet run
Building...
Hello, internet!
^CReceived interrupt signal, attempting to shut down gracefully but will force-close in 5 seconds. Send again to immediately force-close
Hello, internet!
Hello, internet!
^CSecond interrupt received, force-closing app
To see the app force-closed after timing out, keep Driver.cs
as-is, then run the app from the command line, and then send the interrupt
signal once.
Output
:
$ dotnet run
Building...
Hello, internet!
Hello, internet!
Hello, internet!
Hello, internet!
^CReceived interrupt signal, attempting to shut down gracefully but will force-close in 5 seconds. Send again to immediately force-close
Hello, internet!
Hello, internet!
Hello, internet!
Hello, internet!
Hello, internet!
Timeout reached, force-closing the app
Addendum: passing CancellationTokens
to API controller methods
As mentioned earlier, here is how to send the CancellationToken
to an API controller’s method.
First, for this demo, create a csproj
from the webapi
template, and give it namespace WebApiDemo
.
Second, add parameter CancellationToken cancellationToken
to the example endpoint. Here is that function in Controllers/WeatherForecastController.cs
:
using Microsoft.AspNetCore.Mvc;
namespace WebApiDemo.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
// Trimmed for brevity
// Added the cancellationToken parameter; by convention, this is the
// last parameter.
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get(
CancellationToken cancellationToken)
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}