dotnet / dotnet/aspnetcore

HealthCheckPublisherHostedService.StopAsync uses Timer.Dispose() instead of the overload that waits for a running callback

Open
#67,304 2 comments 3 reactions 0 assignees View on GitHub
affected-few area-healthchecks bug severity-minor
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.