diff --git a/build/dependencies.props b/build/dependencies.props
index 93387f8f..d382aa61 100644
--- a/build/dependencies.props
+++ b/build/dependencies.props
@@ -27,6 +27,8 @@
2.2.0-preview3-35359
2.2.0-preview3-35359
2.2.0-preview3-35359
+ 2.2.0-preview3-35359
+ 2.2.0-preview3-35359
2.2.0-preview3-35359
2.2.0-preview3-35359
2.2.0-preview3-35359
diff --git a/samples/WelcomePageSample/web.config b/samples/WelcomePageSample/web.config
deleted file mode 100644
index f7ac6793..00000000
--- a/samples/WelcomePageSample/web.config
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReport.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReport.cs
similarity index 100%
rename from src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReport.cs
rename to src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReport.cs
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReportEntry.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReportEntry.cs
similarity index 100%
rename from src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReportEntry.cs
rename to src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReportEntry.cs
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheckPublisher.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheckPublisher.cs
new file mode 100644
index 00000000..f1809c4b
--- /dev/null
+++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheckPublisher.cs
@@ -0,0 +1,39 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Extensions.Diagnostics.HealthChecks
+{
+ ///
+ /// Represents a publisher of information.
+ ///
+ ///
+ ///
+ /// The default health checks implementation provided an IHostedService implementation that can
+ /// be used to execute health checks at regular intervals and provide the resulting
+ /// data to all registered instances.
+ ///
+ ///
+ /// To provide an implementation, register an instance or type as a singleton
+ /// service in the dependency injection container.
+ ///
+ ///
+ /// instances are provided with a after executing
+ /// health checks in a background thread. The use of depend on hosting in
+ /// an application using IWebHost or generic host (IHost). Execution of
+ /// instance is not related to execution of health checks via a middleware.
+ ///
+ ///
+ public interface IHealthCheckPublisher
+ {
+ ///
+ /// Publishes the provided .
+ ///
+ /// The . The result of executing a set of health checks.
+ /// The .
+ /// A which will complete when publishing is complete.
+ Task PublishAsync(HealthReport report, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs
index 0e907d78..aa12d974 100644
--- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs
+++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs
@@ -126,19 +126,19 @@ private static void ValidateRegistrations(IEnumerable r
}
}
- private static class Log
+ internal static class EventIds
{
- public static class EventIds
- {
- public static readonly EventId HealthCheckProcessingBegin = new EventId(100, "HealthCheckProcessingBegin");
- public static readonly EventId HealthCheckProcessingEnd = new EventId(101, "HealthCheckProcessingEnd");
+ public static readonly EventId HealthCheckProcessingBegin = new EventId(100, "HealthCheckProcessingBegin");
+ public static readonly EventId HealthCheckProcessingEnd = new EventId(101, "HealthCheckProcessingEnd");
- public static readonly EventId HealthCheckBegin = new EventId(102, "HealthCheckBegin");
- public static readonly EventId HealthCheckEnd = new EventId(103, "HealthCheckEnd");
- public static readonly EventId HealthCheckError = new EventId(104, "HealthCheckError");
- public static readonly EventId HealthCheckData = new EventId(105, "HealthCheckData");
- }
+ public static readonly EventId HealthCheckBegin = new EventId(102, "HealthCheckBegin");
+ public static readonly EventId HealthCheckEnd = new EventId(103, "HealthCheckEnd");
+ public static readonly EventId HealthCheckError = new EventId(104, "HealthCheckError");
+ public static readonly EventId HealthCheckData = new EventId(105, "HealthCheckData");
+ }
+ private static class Log
+ {
private static readonly Action _healthCheckProcessingBegin = LoggerMessage.Define(
LogLevel.Debug,
EventIds.HealthCheckProcessingBegin,
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs
index 76b0dc45..d6df03d2 100644
--- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs
+++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
namespace Microsoft.Extensions.DependencyInjection
{
@@ -24,7 +25,8 @@ public static class HealthCheckServiceCollectionExtensions
/// An instance of from which health checks can be registered.
public static IHealthChecksBuilder AddHealthChecks(this IServiceCollection services)
{
- services.TryAdd(ServiceDescriptor.Singleton());
+ services.TryAddSingleton();
+ services.TryAddSingleton();
return new HealthChecksBuilder(services);
}
}
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherHostedService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherHostedService.cs
new file mode 100644
index 00000000..d124ffa2
--- /dev/null
+++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherHostedService.cs
@@ -0,0 +1,262 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Internal;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.Diagnostics.HealthChecks
+{
+ internal sealed class HealthCheckPublisherHostedService : IHostedService
+ {
+ private readonly HealthCheckService _healthCheckService;
+ private readonly IOptions _options;
+ private readonly ILogger _logger;
+ private readonly IHealthCheckPublisher[] _publishers;
+
+ private CancellationTokenSource _stopping;
+ private Timer _timer;
+
+ public HealthCheckPublisherHostedService(
+ HealthCheckService healthCheckService,
+ IOptions options,
+ ILogger logger,
+ IEnumerable publishers)
+ {
+ if (healthCheckService == null)
+ {
+ throw new ArgumentNullException(nameof(healthCheckService));
+ }
+
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ if (logger == null)
+ {
+ throw new ArgumentNullException(nameof(logger));
+ }
+
+ if (publishers == null)
+ {
+ throw new ArgumentNullException(nameof(publishers));
+ }
+
+ _healthCheckService = healthCheckService;
+ _options = options;
+ _logger = logger;
+ _publishers = publishers.ToArray();
+
+ _stopping = new CancellationTokenSource();
+ }
+
+ internal bool IsStopping => _stopping.IsCancellationRequested;
+
+ internal bool IsTimerRunning => _timer != null;
+
+ public Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ if (_publishers.Length == 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ // IMPORTANT - make sure this is the last thing that happens in this method. The timer can
+ // fire before other code runs.
+ _timer = NonCapturingTimer.Create(Timer_Tick, null, dueTime: _options.Value.Delay, period: _options.Value.Period);
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ _stopping.Cancel();
+ }
+ catch
+ {
+ // Ignore exceptions thrown as a result of a cancellation.
+ }
+
+ if (_publishers.Length == 0)
+ {
+ return Task.CompletedTask;
+ }
+
+ _timer?.Dispose();
+ _timer = null;
+
+
+ return Task.CompletedTask;
+ }
+
+ // Yes, async void. We need to be async. We need to be void. We handle the exceptions in RunAsync
+ private async void Timer_Tick(object state)
+ {
+ await RunAsync();
+ }
+
+ // Internal for testing
+ internal async Task RunAsync()
+ {
+ var duration = ValueStopwatch.StartNew();
+ Logger.HealthCheckPublisherProcessingBegin(_logger);
+
+ CancellationTokenSource cancellation = null;
+ try
+ {
+ var timeout = _options.Value.Timeout;
+
+ cancellation = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token);
+ cancellation.CancelAfter(timeout);
+
+ await RunAsyncCore(cancellation.Token);
+
+ Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime());
+ }
+ catch (OperationCanceledException) when (IsStopping)
+ {
+ // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's
+ // a timeout and we want to log it.
+ }
+ catch (Exception ex)
+ {
+ // This is an error, publishing failed.
+ Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime(), ex);
+ }
+ finally
+ {
+ cancellation.Dispose();
+ }
+ }
+
+ private async Task RunAsyncCore(CancellationToken cancellationToken)
+ {
+ // Forcibly yield - we want to unblock the timer thread.
+ await Task.Yield();
+
+ // The health checks service does it's own logging, and doesn't throw exceptions.
+ var report = await _healthCheckService.CheckHealthAsync(_options.Value.Predicate, cancellationToken);
+
+ var publishers = _publishers;
+ var tasks = new Task[publishers.Length];
+ for (var i = 0; i < publishers.Length; i++)
+ {
+ tasks[i] = RunPublisherAsync(publishers[i], report, cancellationToken);
+ }
+
+ await Task.WhenAll(tasks);
+ }
+
+ private async Task RunPublisherAsync(IHealthCheckPublisher publisher, HealthReport report, CancellationToken cancellationToken)
+ {
+ var duration = ValueStopwatch.StartNew();
+
+ try
+ {
+ Logger.HealthCheckPublisherBegin(_logger, publisher);
+
+ await publisher.PublishAsync(report, cancellationToken);
+ Logger.HealthCheckPublisherEnd(_logger, publisher, duration.GetElapsedTime());
+ }
+ catch (OperationCanceledException) when (IsStopping)
+ {
+ // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's
+ // a timeout and we want to log it.
+ }
+ catch (OperationCanceledException ocex)
+ {
+ Logger.HealthCheckPublisherTimeout(_logger, publisher, duration.GetElapsedTime());
+ throw ocex;
+ }
+ catch (Exception ex)
+ {
+ Logger.HealthCheckPublisherError(_logger, publisher, duration.GetElapsedTime(), ex);
+ throw ex;
+ }
+ }
+
+ internal static class EventIds
+ {
+ public static readonly EventId HealthCheckPublisherProcessingBegin = new EventId(100, "HealthCheckPublisherProcessingBegin");
+ public static readonly EventId HealthCheckPublisherProcessingEnd = new EventId(101, "HealthCheckPublisherProcessingEnd");
+ public static readonly EventId HealthCheckPublisherProcessingError = new EventId(101, "HealthCheckPublisherProcessingError");
+
+ public static readonly EventId HealthCheckPublisherBegin = new EventId(102, "HealthCheckPublisherBegin");
+ public static readonly EventId HealthCheckPublisherEnd = new EventId(103, "HealthCheckPublisherEnd");
+ public static readonly EventId HealthCheckPublisherError = new EventId(104, "HealthCheckPublisherError");
+ public static readonly EventId HealthCheckPublisherTimeout = new EventId(104, "HealthCheckPublisherTimeout");
+ }
+
+ private static class Logger
+ {
+ private static readonly Action _healthCheckPublisherProcessingBegin = LoggerMessage.Define(
+ LogLevel.Debug,
+ EventIds.HealthCheckPublisherProcessingBegin,
+ "Running health check publishers");
+
+ private static readonly Action _healthCheckPublisherProcessingEnd = LoggerMessage.Define(
+ LogLevel.Debug,
+ EventIds.HealthCheckPublisherProcessingEnd,
+ "Health check publisher processing completed after {ElapsedMilliseconds}ms");
+
+ private static readonly Action _healthCheckPublisherBegin = LoggerMessage.Define(
+ LogLevel.Debug,
+ EventIds.HealthCheckPublisherBegin,
+ "Running health check publisher '{HealthCheckPublisher}'");
+
+ private static readonly Action _healthCheckPublisherEnd = LoggerMessage.Define(
+ LogLevel.Debug,
+ EventIds.HealthCheckPublisherEnd,
+ "Health check '{HealthCheckPublisher}' completed after {ElapsedMilliseconds}ms");
+
+ private static readonly Action _healthCheckPublisherError = LoggerMessage.Define(
+ LogLevel.Error,
+ EventIds.HealthCheckPublisherError,
+ "Health check {HealthCheckPublisher} threw an unhandled exception after {ElapsedMilliseconds}ms");
+
+ private static readonly Action _healthCheckPublisherTimeout = LoggerMessage.Define(
+ LogLevel.Error,
+ EventIds.HealthCheckPublisherTimeout,
+ "Health check {HealthCheckPublisher} was canceled after {ElapsedMilliseconds}ms");
+
+ public static void HealthCheckPublisherProcessingBegin(ILogger logger)
+ {
+ _healthCheckPublisherProcessingBegin(logger, null);
+ }
+
+ public static void HealthCheckPublisherProcessingEnd(ILogger logger, TimeSpan duration, Exception exception = null)
+ {
+ _healthCheckPublisherProcessingEnd(logger, duration.TotalMilliseconds, exception);
+ }
+
+ public static void HealthCheckPublisherBegin(ILogger logger, IHealthCheckPublisher publisher)
+ {
+ _healthCheckPublisherBegin(logger, publisher, null);
+ }
+
+ public static void HealthCheckPublisherEnd(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration)
+ {
+ _healthCheckPublisherEnd(logger, publisher, duration.TotalMilliseconds, null);
+ }
+
+ public static void HealthCheckPublisherError(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration, Exception exception)
+ {
+ _healthCheckPublisherError(logger, publisher, duration.TotalMilliseconds, exception);
+ }
+
+ public static void HealthCheckPublisherTimeout(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration)
+ {
+ _healthCheckPublisherTimeout(logger, publisher, duration.TotalMilliseconds, null);
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherOptions.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherOptions.cs
new file mode 100644
index 00000000..1313718a
--- /dev/null
+++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherOptions.cs
@@ -0,0 +1,84 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.Extensions.Diagnostics.HealthChecks
+{
+ ///
+ /// Options for the default service that executes instances.
+ ///
+ public sealed class HealthCheckPublisherOptions
+ {
+ private TimeSpan _delay;
+ private TimeSpan _period;
+
+ public HealthCheckPublisherOptions()
+ {
+ _delay = TimeSpan.FromSeconds(5);
+ _period = TimeSpan.FromSeconds(30);
+ }
+
+ ///
+ /// Gets or sets the initial delay applied after the application starts before executing
+ /// instances. The delay is applied once at startup, and does
+ /// not apply to subsequent iterations. The default value is 5 seconds.
+ ///
+ public TimeSpan Delay
+ {
+ get => _delay;
+ set
+ {
+ if (value == System.Threading.Timeout.InfiniteTimeSpan)
+ {
+ throw new ArgumentException($"The {nameof(Delay)} must not be infinite.", nameof(value));
+ }
+
+ _delay = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the period of execution. The default value is
+ /// 30 seconds.
+ ///
+ ///
+ /// The cannot be set to a value lower than 1 second.
+ ///
+ public TimeSpan Period
+ {
+ get => _period;
+ set
+ {
+ if (value < TimeSpan.FromSeconds(1))
+ {
+ throw new ArgumentException($"The {nameof(Period)} must be greater than or equal to one second.", nameof(value));
+ }
+
+ if (value == System.Threading.Timeout.InfiniteTimeSpan)
+ {
+ throw new ArgumentException($"The {nameof(Period)} must not be infinite.", nameof(value));
+ }
+
+ _delay = value;
+ }
+ }
+
+ ///
+ /// Gets or sets a predicate that is used to filter the set of health checks executed.
+ ///
+ ///
+ /// If is null, the health check publisher service will run all
+ /// registered health checks - this is the default behavior. To run a subset of health checks,
+ /// provide a function that filters the set of checks. The predicate will be evaluated each period.
+ ///
+ public Func Predicate { get; set; }
+
+ ///
+ /// Gets or sets the timeout for executing the health checks an all
+ /// instances. Use to execute with no timeout.
+ /// The default value is 30 seconds.
+ ///
+ public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
+ }
+}
diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj
index 72522602..52380390 100644
--- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj
+++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj
@@ -12,8 +12,8 @@ Microsoft.Extensions.Diagnostics.HealthChecks.IHealthChecksBuilder
diagnostics;healthchecks
-
-
+
+
diff --git a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj
index 8ac9320a..8f7cd5e9 100644
--- a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj
+++ b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj
@@ -1,4 +1,4 @@
-
+
$(StandardTestTfms)
@@ -9,6 +9,7 @@
+
diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs
index 34b92b7b..694a9762 100644
--- a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs
+++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs
@@ -1,7 +1,9 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+using System.Linq;
using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
using Xunit;
namespace Microsoft.Extensions.DependencyInjection
@@ -19,7 +21,7 @@ public void AddHealthChecks_RegistersSingletonHealthCheckServiceIdempotently()
services.AddHealthChecks();
// Assert
- Assert.Collection(services,
+ Assert.Collection(services.OrderBy(s => s.ServiceType.FullName),
actual =>
{
Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime);
@@ -27,6 +29,14 @@ public void AddHealthChecks_RegistersSingletonHealthCheckServiceIdempotently()
Assert.Equal(typeof(DefaultHealthCheckService), actual.ImplementationType);
Assert.Null(actual.ImplementationInstance);
Assert.Null(actual.ImplementationFactory);
+ },
+ actual =>
+ {
+ Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime);
+ Assert.Equal(typeof(IHostedService), actual.ServiceType);
+ Assert.Equal(typeof(HealthCheckPublisherHostedService), actual.ImplementationType);
+ Assert.Null(actual.ImplementationInstance);
+ Assert.Null(actual.ImplementationFactory);
});
}
}
diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckPublisherHostedServiceTest.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckPublisherHostedServiceTest.cs
new file mode 100644
index 00000000..49d57916
--- /dev/null
+++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckPublisherHostedServiceTest.cs
@@ -0,0 +1,528 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Testing;
+using Xunit;
+
+namespace Microsoft.Extensions.Diagnostics.HealthChecks
+{
+ public class HealthCheckPublisherHostedServiceTest
+ {
+ [Fact]
+ public async Task StartAsync_WithoutPublishers_DoesNotStartTimer()
+ {
+ // Arrange
+ var publishers = new IHealthCheckPublisher[]
+ {
+ };
+
+ var service = CreateService(publishers);
+
+ try
+ {
+ // Act
+ await service.StartAsync();
+
+ // Assert
+ Assert.False(service.IsTimerRunning);
+ Assert.False(service.IsStopping);
+ }
+ finally
+ {
+ await service.StopAsync();
+ Assert.False(service.IsTimerRunning);
+ Assert.True(service.IsStopping);
+ }
+ }
+
+ [Fact]
+ public async Task StartAsync_WithPublishers_StartsTimer()
+ {
+ // Arrange
+ var publishers = new IHealthCheckPublisher[]
+ {
+ new TestPublisher(),
+ };
+
+ var service = CreateService(publishers);
+
+ try
+ {
+ // Act
+ await service.StartAsync();
+
+ // Assert
+ Assert.True(service.IsTimerRunning);
+ Assert.False(service.IsStopping);
+ }
+ finally
+ {
+ await service.StopAsync();
+ Assert.False(service.IsTimerRunning);
+ Assert.True(service.IsStopping);
+ }
+ }
+
+ [Fact]
+ public async Task StartAsync_WithPublishers_StartsTimer_RunsPublishers()
+ {
+ // Arrange
+ var unblock0 = new TaskCompletionSource