I have a .NET 8 web api that has a HostedService and a controller. The controller uses an interface that the HostedService also implements so that the service may be started, stopped, or restarted. By default, the intent is for no intervention.
The StartAsync method is using a PeriodicTimer so that work will be attempted every 15 seconds.
The issue I have is that when an attempt is made to stop the service, it does not stop. I have tried everything I can think of and was hoping to see if anyone can point out what I am doing incorrectly. When I say it does not stop, after the periodictimer ticks expire, another cycle of work is started. CancellationToken should be thread safe and yet it is not catching that the work is cancelled.
Code
Program.cs
using HostedService.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<IControllableBackgroundService, UserOfficeHostedService>();
builder.Services.AddHostedService<UserOfficeHostedService>();
builder.Services.Configure<HostOptions>(x =>
{
x.ServicesStartConcurrently = true;
x.ServicesStopConcurrently = false;
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors(x => x
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
);
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
IControllableBackgroundService
namespace HostedService.Services;
public interface IControllableBackgroundService
{
Task StartServiceAsync();
Task StopServiceAsync();
Task RestartServiceAsync();
}
BackgroundServiceController
using HostedService.Services;
using Microsoft.AspNetCore.Mvc;
namespace HostedService.Controllers;
[ApiController]
[Route("[controller]")]
public class BackgroundServiceController(IControllableBackgroundService backgroundService) : ControllerBase
{
[HttpGet]
[Route("start")]
public async Task<IActionResult> StartService()
{
await backgroundService.StartServiceAsync();
return Ok();
}
[HttpGet]
[Route("stop")]
public async Task<IActionResult> StopService()
{
await backgroundService.StopServiceAsync();
return Ok();
}
[HttpGet]
[Route("restart")]
public async Task<IActionResult> RestartService()
{
await backgroundService.RestartServiceAsync();
return Ok();
}
}
UserOfficeHostedService
namespace HostedService.Services;
public class UserOfficeHostedService(
ILogger<UserOfficeHostedService> logger) : IHostedService, IControllableBackgroundService, IDisposable
{
private CancellationTokenSource cts = new();
private PeriodicTimer timer = new (TimeSpan.FromSeconds(15)));
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("starting service");
cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
while (await timer.WaitForNextTickAsync(cts.Token))
{
if (cts.Token.IsCancellationRequested)
break;
await UpdateUserOfficeCacheAsync(cts.Token);
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("stopping service");
await cts.CancelAsync();
timer.Dispose();
}
public async Task StartServiceAsync()
{
if (!cts.IsCancellationRequested)
return;
timer = new PeriodicTimer(TimeSpan.FromSeconds(serviceInterval));
await StartAsync(cts.Token);
}
public async Task StopServiceAsync()
{
await cts.CancelAsync();
await StopAsync(CancellationToken.None);
}
public async Task RestartServiceAsync()
{
await StopAsync(cts.Token);
timer = new PeriodicTimer(TimeSpan.FromSeconds(serviceInterval));
await StartAsync(cts.Token);
}
private async Task UpdateUserOfficeCacheAsync(CancellationToken cancellationToken)
{
logger.LogInformation($"Performing update on UserOffice Cache. Time: {DateTimeOffset.Now}");
try
{
if (cancellationToken.IsCancellationRequested)
{
logger.LogInformation($"Cancellation received before work attempted. Time: {DateTimeOffset.Now}");
return;
}
await Task.Delay(16000, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
logger.LogInformation($"Cancellation received after work started. Time: {DateTimeOffset.Now}");
}
}
catch (TaskCanceledException)
{
logger.LogInformation("UserOffice Cache update was canceled.");
}
finally
{
logger.LogInformation("UserOffice Background Service released semaphore.");
}
}
public void Dispose()
{
timer?.Dispose();
cts?.Dispose();
}
}
Results
info: HostedService.Services.UserOfficeHostedService[0]
starting service
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7016
info: HostedService.Services.UserOfficeHostedService[0]
Performing update on UserOffice Cache. Time: 7/16/2024 4:41:51 PM -04:00
info: HostedService.Services.UserOfficeHostedService[0]
stopping service
info: HostedService.Services.UserOfficeHostedService[0]
UserOffice Background Service released semaphore.
info: HostedService.Services.UserOfficeHostedService[0]
Performing update on UserOffice Cache. Time: 7/16/2024 4:42:07 PM -04:00
As you can see, the service was stopped and then more work started. How do I fix this?
What I have tried:
I have tried the BackgroundService and now the IHostedService with various incarnations of CancellationTokenSource and CancellationToken without success. The service keeps restarting.
Additionally, various timers have been used as well.