HealthCheckPublisherHostedService.StopAsync uses Timer.Dispose() instead of the overload that waits for a running callback
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 281
Description
### Is there an existing issue for this?
- [x] I have searched the existing issues
### Describe the bug
In `HealthCheckPublisherHostedService.StopAsync`, the timers are closed with the simple `Timer.Dispose()`:
```csharp
if (_timers != null)
{
foreach (var timer in _timers)
{
timer.Dispose();
}
_timers = null;
}
```
`System.Threading.Timer.Dispose()` does not wait for a callback that is already running. It frees the timer and returns right away. So if a timer callback is still running when `StopAsync` runs, `StopAsync` returns while that callback keeps going.
`Timer` has other overloads that do wait. `Dispose(WaitHandle notifyObject)` signals when all callbacks are done. `DisposeAsync()` returns a task that finishes when the callback finishes. `StopAsync` uses neither. So a publish that started just before the stop keeps running after the hosted service has stopped.
The docs already note this about the sample TimedHostedService ("the Timer doesn't wait for previous executions of DoWork to finish"). But here the type is `internal sealed`, so we cannot change the dispose call or wait for the running publish ourselves. The fix has to be in the framework.
Source (main): https://github.com/dotnet/aspnetcore/blob/main/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs
### Expected Behavior
`StopAsync` should not finish while a timer callback is still running. A waiting dispose overload (`Dispose(WaitHandle)` or `DisposeAsync`) would make the stop reliable, so no publish runs after the service has stopped.
### Steps To Reproduce
Minimal repro (public repo, Empty console host, only Hosting + HealthChecks):
https://github.com/youribroskij/healthcheck-publisher-stopasync-repro
Run `dotnet run`. The program:
1. Starts a host with one health check and one `IHealthCheckPublisher` whose `PublishAsync` runs for ~3s.
2. Waits until a publish is in flight.
3. Calls `await host.StopAsync()` and records when it returns.
4. Shows that `StopAsync` returns while the publish is still running, and the publish finishes after the stop returned.
Sample output:
```
[ 207 ms] PublishAsync STARTED (will run ~3000 ms)
[ 208 ms] calling host.StopAsync()...
[ 209 ms] host.StopAsync() RETURNED
PublishRunning immediately after StopAsync returned = True
[ 3214 ms] PublishAsync FINISHED (publish finished AFTER stop)
```
### Exceptions (if any)
_No response_
### .NET Version
8.0.411
### Anything else?
ASP.NET Core version: Microsoft.Extensions.Diagnostics.HealthChecks 8.0.0 (Microsoft.Extensions.Hosting 8.0.0), target net8.0
IDE: JetBrains Rider 2026.1.2
.NET SDK:
Version: 8.0.411
Commit: f97ff31961
Workload version: 8.0.400-manifests.ee7a832d
MSBuild version: 17.11.31+933b72e36
Runtime Environment:
OS Name: Mac OS X
OS Version: 26.5
OS Platform: Darwin
RID: osx-arm64
Base Path: /Users/youribroskij/.dotnet/sdk/8.0.411/
Host:
Version: 10.0.7
Architecture: arm64
Commit: b16286c228
.NET SDKs installed:
8.0.411, 9.0.100, 9.0.202, 9.0.205, 9.0.301, 10.0.203
Links / references:
- Repro: https://github.com/youribroskij/healthcheck-publisher-stopasync-repro
- Source on main: https://github.com/dotnet/aspnetcore/blob/main/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs
- Docs note that the sample Timer "doesn't wait for previous executions of DoWork to finish": https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services
Contributor guide
Research direction
Start with src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs and inspect StopAsync alongside the timer callback lifecycle. Run the linked minimal repro to observe the current timing, then verify that stopping the hosted service does not return until an in-flight PublishAsync callback has finished.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- backend, observability
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100