A Beginner’s Guide to .NET’s HostBuilder: Part 3 — Cancellation

Sawyer Watts
4 min readOct 28, 2023

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

  1. Introduction and Setup
  2. Dependency injection
  3. Cancellation
  4. Configurations and Environments
  5. 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 workerand 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();
}
}

--

--