Skip to content

Fixed #1131 - Race condition between SuspendNotifications() and .ResumeNotifications()#1132

Open
JakenVeina wants to merge 3 commits into
mainfrom
issues/1131
Open

Fixed #1131 - Race condition between SuspendNotifications() and .ResumeNotifications()#1132
JakenVeina wants to merge 3 commits into
mainfrom
issues/1131

Conversation

@JakenVeina

Copy link
Copy Markdown
Collaborator

Fixed that ObservableCache.SuspendNotifications() uses a lock to synchronize updates to internal state for tracking notification suspensions, but .ResumeNotifications() did not use any synchronization when updating that same state, allowing for corruption of state if the two methods happen to overlap, concurrently.

Also fixed that the test for this behavior was intermittent, specifically that it tended to falsely pass when running in parallel with many other tests. The test has now been moved to its own IntegrationTests fixture, to guarantee it is never parallelized with other tests.

Resolves #1131.

@dwcullop I suspect this may not be a proper fix, in the context of DeliveryQueue<>. I.E. this solution involves using a basic lock() when perhaps the proper solution should be using the locking mechanisms upon DeliveryQueue<>. But I don't have the familiarity with it. For now, I just duplicated the existing lock(_locker) upon .SuspendNotifications() over to .ResumeNotifications(). If you'd rather take a crack at this yourself instead, go for it.

The test changes we should definitely keep, though.

…synchronize updates to internal state for tracking notification suspensions, but `.ResumeNotifications()` did not use any synchronization when updating that same state, allowing for corruption of state if the two methods happen to overlap, concurrently.

Also fixed that the test for this behavior was intermittent, specifically that it tended to falsely pass when running in parallel with many other tests. The test has now been moved to its own `IntegrationTests` fixture, to guarantee it is never parallelized with other tests.

Resolves #1131.
@dwcullop

dwcullop commented Jul 9, 2026

Copy link
Copy Markdown
Member

The lock that you're using is the same lock the DQ uses. However, doing the Resume while holding the lock will cause the changes to be emitted while holding the lock. Let me see if I can suggest an alternative.

dwcullop added 2 commits July 9, 2026 18:38
…thout holding it

ResumeNotifications() was wrapped in lock(_locker), which via lock reentrancy
held the cache lock across the DeliveryQueue drain, delivering accumulated
changes to subscribers while the lock was held. Restore off-lock delivery and
close the suspend/resume state-divergence race a different way:

- SuspendNotifications() no longer wraps the resume callback in the cache lock;
  queued changes drain outside the lock, as the DeliveryQueue intends.
- ResumeNotifications() re-checks the suspend count under the lock before
  emitting the resume signal, so a concurrent SuspendNotifications() in the
  decrement/emit window wins and the count and the subject cannot diverge.
- SuspensionTracker.SuspendNotifications() gates the suspended signal on the
  subject's own value rather than the count, keeping it monotonic while a
  resume's signal is still in flight.

Tests:
- Add StaleResumeSignalIsSuppressedByConcurrentReSuspend: deterministic proof
  that a connection made during a racing re-suspend does not activate on a
  stale resume signal (fails without the count re-check).
- Add ResumeDeliversPendingChangesWithoutHoldingTheLock: proves a blocked
  subscriber does not stall a concurrent lock-requiring operation during
  resume delivery.
- Remove ResumeSignalUnderLockPreventsStaleSnapshotFromReSuspend: redundant
  coverage that only passed by timing out a deadlock.
public sealed class IntegrationTests
: IntegrationTestFixtureBase
{
[Fact]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Any particular reason to move this?

{
using var readLock = _notifications.AcquireReadLock();
_suspensionTracker.Value.EmitResumeNotification();
if (!_suspensionTracker.Value.AreNotificationsSuspended)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

So, we're basically saying "state corruption is cool, if we just guard against the impact"? I don't think this is sound the way it's currently implemented. In particular, .SuspendNotifications() does not acquire any locks from _notifications, so even with this lock here, it's possible that a concurrent .SuspendNotifications() can swap _suspensionTracker.Value.AreNotificationsSuspended from false to true in the time between this check, and the call to .EmitResumeNotification().

// separate steps, so the count can briefly reach zero before the 'false' signal
// is emitted. Gating on the subject keeps the signal monotonic and avoids a
// redundant 'true' when a suspend interleaves in that window.
if (!_areNotificationsSuspended.IsDisposed && !_areNotificationsSuspended.Value)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Would Interlocked.Increment() and Interlocked.Decrement() not make more sense for tracking _notifySuspendCount?

Otherwise, does _notifySuspendCount even have a purpose anymore?

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.

[Bug]: Intermittent test failure for in SuspendNotificationsFixture

2 participants