Skip to content

[release/10.2] Backport: Fix deadlock in ServiceEndpointWatcher when disposing change token registration#7259

Closed
joperezr wants to merge 3 commits intorelease/10.2from
joperezr/Backport7255
Closed

[release/10.2] Backport: Fix deadlock in ServiceEndpointWatcher when disposing change token registration#7259
joperezr wants to merge 3 commits intorelease/10.2from
joperezr/Backport7255

Conversation

@joperezr
Copy link
Member

@joperezr joperezr commented Feb 3, 2026

cc: @ReubenBond

Microsoft Reviewers: Open in CodeFlow

…gistration

Move _changeTokenRegistration.Dispose() outside the lock to avoid deadlock.
CancellationTokenRegistration.Dispose() blocks waiting for any in-flight
callback to complete, but the callback (RefreshAsync) tries to acquire
the same lock, causing a deadlock.

The fix captures the registration reference while holding the lock, then
disposes it after releasing the lock. Applied to both RefreshAsyncInternal
and DisposeAsync methods.
@joperezr
Copy link
Member Author

joperezr commented Feb 3, 2026

Closing as dupe for #7258

@joperezr joperezr closed this Feb 3, 2026
@joperezr joperezr deleted the joperezr/Backport7255 branch February 3, 2026 22:44
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This is a backport to the release/10.2 branch that fixes a deadlock issue in ServiceEndpointWatcher when disposing change token registrations and timers. The deadlock occurred when Dispose() was called inside a lock while callbacks were waiting to acquire the same lock, causing Dispose() to wait for the callback to complete, creating a circular wait.

Changes:

  • Modified disposal pattern to capture disposable resources inside locks and dispose them outside locks
  • Applied this pattern consistently to RefreshAsyncInternal(), SchedulePollingTimer(), and DisposeAsync() methods
  • Added explanatory comments documenting the deadlock scenario and why this pattern is necessary

_pollingTimer?.Dispose();
_pollingTimer = null;
return;
timerToDispose = Interlocked.Exchange(ref _pollingTimer, null);
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

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

Using Interlocked.Exchange inside a lock is unnecessary and inconsistent with the pattern used in RefreshAsyncInternal (lines 144, 174) and DisposeAsync (lines 283-286), where simple assignment is used inside the lock. Since this code is already protected by the lock, a simple assignment would be more appropriate and consistent:

timerToDispose = _pollingTimer;
_pollingTimer = null;

The Interlocked.Exchange provides thread-safe atomic operations, but that's already guaranteed by the lock.

Suggested change
timerToDispose = Interlocked.Exchange(ref _pollingTimer, null);
timerToDispose = _pollingTimer;
_pollingTimer = null;

Copilot uses AI. Check for mistakes.
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.

2 participants

Comments