Skip to content

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

Description

@youribroskij

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

In HealthCheckPublisherHostedService.StopAsync, the timers are closed with the simple Timer.Dispose():

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:

Activity

  1. added theissue type on Jun 18, 2026
  2. added
    bugThis issue describes a behavior which is not expected - a bug.
    on Aug 26, 2026
  3. added this to the Backlog milestone on Aug 26, 2026
  4. added
    severity-minorThis label is used by an internal tool
    affected-fewThis issue impacts only small number of customers
    on Aug 26, 2026
  5. SergioAlmeida29 commented on Aug 27, 2026

    @SergioAlmeida29

    I investigated this and found that the timer callback is async void, so replacing Timer.Dispose() alone would not wait for the actual RunAsync/PublishAsync work. I am interested in implementing the fix with tracking and regression tests.

    Could you confirm the intended shutdown guarantee for in-flight publishers, behavior when cancellation/timeout is ignored, compatibility with netstandard2.0/net462, and whether overlapping executions should remain supported?

  6. youribroskij commented on Sep 1, 2026

    @youribroskij
    Author

    @SergioAlmeida29 Thanks for looking into this.

    1. Intended shutdown guarantee

    My expectation as a user was that StopAsync does not return while a publish it started is still in flight, bounded by the token passed to StopAsync.

    2. When cancellation or timeout is ignored

    I would not expect the framework to be able to stop a publisher that ignores its token, so I would not ask for a hard guarantee there.

    3. netstandard2.0 / net462

    I have no requirement here. My repro targets net8.0 only, and I hit this on Microsoft.Extensions.Diagnostics.HealthChecks 8.0.0.

    4. Overlapping executions

    Nothing in my report depends on changing that I think, I reported specifically on the shutdown behavior, not the scheduling behavior. From my side overlapping executions can stay exactly as they are today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    affected-fewThis issue impacts only small number of customersarea-healthchecksIncludes: Healthchecks (some bugs also in Extensions repo)bugThis issue describes a behavior which is not expected - a bug.severity-minorThis label is used by an internal tool

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions