diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticHostedService.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticHostedService.cs index 9ffd85f56..2d7b355ab 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticHostedService.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticHostedService.cs @@ -13,19 +13,30 @@ internal class DiagnosticHostedService(DiagnosticSummary diagnosticSummary, IOpt protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(options.Value.Diagnostics.LogFrequency); - while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) + try { - try + while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { - await diagnosticSummary.PrintSummary(); - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred while logging the diagnostic summary: {Message}", ex.Message); + try + { + await diagnosticSummary.PrintSummary(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while logging the diagnostic summary: {Message}", + ex.Message); + } } } + catch (OperationCanceledException) + { + // When stopping this hosted service, "await timer.WaitForNextTickAsync(stoppingToken)" can throw an OperationCanceledException. + } } + // Added for testing purposes to be able to call ExecuteAsync directly. + internal Task ExecuteForTestOnly(CancellationToken stoppingToken) => ExecuteAsync(stoppingToken); + public override async Task StopAsync(CancellationToken cancellationToken) { await diagnosticSummary.PrintSummary(); diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs new file mode 100644 index 000000000..9fba275d3 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text.Json; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace IdentityServer.UnitTests.Licensing.V2; + +public class DiagnosticHostedServiceTests +{ + [Fact] + public async Task ExecuteAsync_ShouldNotThrowOperationCancelledException() + { + var diagnosticSummaryLogger = new NullLogger(); + var firstDiagnosticEntry = new TestDiagnosticEntry(); + var secondDiagnosticEntry = new TestDiagnosticEntry(); + var thirdDiagnosticEntry = new TestDiagnosticEntry(); + var entries = new List + { + firstDiagnosticEntry, + secondDiagnosticEntry, + thirdDiagnosticEntry + }; + var diagnosticSummary = new DiagnosticSummary(DateTime.UtcNow, entries, new IdentityServerOptions(), new StubLoggerFactory(diagnosticSummaryLogger)); + + var options = Options.Create(new IdentityServerOptions()); + var logger = new NullLogger(); + + var service = new DiagnosticHostedService(diagnosticSummary, options, logger); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(100); + + var exception = await Record.ExceptionAsync(async () => + { + await service.ExecuteForTestOnly(cts.Token); + }); + + exception.ShouldBeNull(); + } + + private class TestDiagnosticEntry : IDiagnosticEntry + { + public Task WriteAsync(DiagnosticContext context, Utf8JsonWriter writer) => Task.CompletedTask; + } +}