Skip to content

fix: CircuitWatcher.Dispose() does not stop the ping-until-reconnected loop#3326

Merged
jeremydmiller merged 2 commits into
JasperFx:mainfrom
lahma:fix/circuit-watcher-dispose-leak
Jul 6, 2026
Merged

fix: CircuitWatcher.Dispose() does not stop the ping-until-reconnected loop#3326
jeremydmiller merged 2 commits into
JasperFx:mainfrom
lahma:fix/circuit-watcher-dispose-leak

Conversation

@lahma

@lahma lahma commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

CircuitWatcher only stores the caller's CancellationToken and its Dispose() just disposes the Task wrapper -- neither actually stops pingUntilConnectedAsync, which keeps running against the still-live caller token. SendingAgent's own DisposeAsync() never disposed the circuit watcher either, so once a sender's circuit trips, it keeps pinging a dead destination forever, even after the owning host/agent has been disposed.

I hit this concretely with the Kafka transport: a test host pointed at a permanently unreachable broker (used deliberately to exercise startup config validation) never exited, because the producer's circuit-breaker ping loop kept retrying in the background indefinitely regardless of how fast/slow each connection attempt failed -- disposing the WebApplicationFactory did not stop it. The same gap applies to any transport that goes through SendingAgent's circuit-breaker path, not just Kafka.

Fix

  • CircuitWatcher now links the caller's token into its own CancellationTokenSource and cancels that source in Dispose(), so Dispose() can actually stop the loop instead of only releasing the Task wrapper.
  • SendingAgent.DisposeAsync() now disposes the circuit watcher (mirroring the existing cleanup already done in MarkSuccessAsync()) before tearing down the sender.

Test plan

  • Added circuit_watcher_dispose_stops_the_ping_loop (isolated CircuitWatcher + StubCircuit) and disposing_a_sending_agent_stops_its_circuit_watcher (end-to-end via BufferedSendingAgent) to CoreTests/Transports/Sending/.
  • Verified both new tests fail without the fix (ping count keeps climbing after Dispose()/DisposeAsync()) and pass with it.
  • dotnet build src/Wolverine/Wolverine.csproj and dotnet build src/Testing/CoreTests/CoreTests.csproj: clean.
  • Full CoreTests suite: 1856 passed, 4 pre-existing failures unrelated to this change (confirmed identical failures on unmodified main: Bug_2896_mixed_lifetime_enumerable_dependency and 3 result_types_end_to_end tests, all "Cannot resolve scoped service ... from root provider" -- a DI scoping issue unrelated to sending/circuit-breaker code).

…d loop

CircuitWatcher only stored the caller's CancellationToken and disposed the
Task wrapper on Dispose() -- neither actually stops pingUntilConnectedAsync,
which keeps running against the still-live caller token. SendingAgent's own
DisposeAsync() never called circuit watcher disposal either, so a sender
whose circuit had tripped kept pinging a dead destination forever after the
owning host was disposed.

Reproduced with a Kafka producer pointed at a permanently unreachable
broker: disposing the WebApplicationFactory-hosted app never stopped the
background reconnect/ping loop, keeping the test process alive
indefinitely regardless of how quickly each connection attempt failed.

- CircuitWatcher now links the caller's token into its own
  CancellationTokenSource and cancels it in Dispose(), so Dispose() can stop
  the loop on its own instead of only releasing the Task wrapper.
- SendingAgent.DisposeAsync() now disposes the circuit watcher before
  tearing down the sender.

Added regression tests exercising both the isolated CircuitWatcher and a
BufferedSendingAgent end-to-end; both fail (ping count keeps climbing after
Dispose()) without this fix and pass with it.
Copilot AI review requested due to automatic review settings July 6, 2026 17:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a shutdown/leak issue where CircuitWatcher’s background “ping until reconnected” loop could continue running after disposal, and ensures SendingAgent properly tears down its circuit watcher during shutdown to prevent indefinite background retries.

Changes:

  • Link CircuitWatcher’s cancellation to an internal CancellationTokenSource and cancel it on Dispose() to stop the ping loop.
  • Dispose the active circuit watcher in SendingAgent.DisposeAsync() before disposing the underlying sender.
  • Add/adjust tests to assert circuit-watcher activity stops after disposal (both isolated and via BufferedSendingAgent).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/Wolverine/Transports/Sending/SendingAgent.cs Disposes the circuit watcher during agent shutdown to stop background pinging.
src/Wolverine/Transports/Sending/CircuitWatcher.cs Introduces a linked CTS and cancellation on dispose so the ping loop can be stopped.
src/Testing/CoreTests/Transports/Sending/SendingAgentDisposalTests.cs Adds regression tests for stopping the ping loop on watcher/agent disposal.
src/Testing/CoreTests/Transports/Sending/CircuitWatcherTester.cs Makes the stub circuit’s call counting/threading and retry interval configurable for new tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 40 to 44
private async Task pingUntilConnectedAsync()
{
using var timer=new PeriodicTimer(_senderCircuit.RetryInterval);
while (await timer.WaitForNextTickAsync(_cancellation))
while (await timer.WaitForNextTickAsync(_cancellation.Token))
{
Comment on lines +24 to +28
watcher.Dispose();

var countAtDispose = circuit.CallCount;
await Task.Delay(200.Milliseconds());

Comment on lines +56 to +60
await agent.DisposeAsync();

var pingCountAtDispose = sender.PingCount;
await Task.Delay(200.Milliseconds());

- pingUntilConnectedAsync now wraps the whole loop (not just the inner
  ping) in a try/catch: cancelling and disposing the linked
  CancellationTokenSource in Dispose() can surface as
  OperationCanceledException from the timer wait, or ObjectDisposedException
  if a token registration races the dispose. Both are expected shutdown
  outcomes, not faults.
- Both new tests settle for one retry interval after Dispose()/DisposeAsync()
  before taking the "count so far" baseline, so an already-in-flight ping
  can't tick over during the assertion window and cause a spurious failure.
@lahma

lahma commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Good catches, thanks — pushed a fix for both:

  • pingUntilConnectedAsync now wraps the whole loop in a try/catch, not just the inner ping call, so OperationCanceledException/ObjectDisposedException from the timer wait or a token-registration race during Dispose() is swallowed as an expected shutdown rather than surfacing as a faulted/unobserved task.
  • Both new tests now settle for one retry interval after Dispose()/DisposeAsync() before capturing the baseline count, so an already-in-flight ping can't tick over during the assertion window and cause a spurious failure. Ran both 5x locally with no flakes after the change.

@jeremydmiller
jeremydmiller merged commit 25d7cb8 into JasperFx:main Jul 6, 2026
24 of 26 checks passed
This was referenced Jul 9, 2026
This was referenced Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants