Skip to content

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

Merged
JakenVeina merged 5 commits into
mainfrom
issues/1131
Jul 27, 2026
Merged

Fixed #1131 - Race condition between SuspendNotifications() and .ResumeNotifications()#1132
JakenVeina merged 5 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.

@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.

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?

Comment thread src/DynamicData/Cache/ObservableCache.cs Outdated
Comment thread src/DynamicData/Cache/ObservableCache.cs Outdated

@dwcullop dwcullop left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review summary

Verdict: approve with minor changes. The invariant restored here is correct, and the test isolation does what it claims.

Note for context: findings 1 through 3 below are against head commit 6911074d, not @JakenVeina's original commit. The approach in the head commit is a better fix than the plain lock() duplication originally proposed.

Validation performed

  • Full net9.0 suite on the PR branch: 2439 passed, 0 failed, 38s.
  • SuspendNotificationsFixture in isolation: 24 passed in 170ms, and still 170ms when run concurrently with a second full test suite saturating every core. The move to the non-parallel IntegrationTests collection achieves its goal.
  • Merges cleanly with #1133 (no textual conflicts) and is semantically compatible with it.

Correctness analysis

I traced the invariant _notifySuspendCount > 0 <=> _areNotificationsSuspended.Value == true through every interleaving. It holds:

  • false can never be emitted while the count is above zero, because the re-check and the OnNext are both inside the same _locker acquisition and no other thread can mutate the count without that lock.
  • false can never be lost, because whichever resumer observes a zero count last will emit it.
  • true can never be emitted twice, because the gate reads the subject's own value under _locker.
  • A duplicate false is possible when two resumers race in the window after the enqueue scope is released. It is harmless behind Where(b => !b).Take(1), but worth knowing.

4. Please call out the behavioral change

ResumeSignalUnderLockPreventsStaleSnapshotFromReSuspend was removed, and it asserted the opposite of the new behavior: that a deferred Connect() activates on a resume signal that is immediately followed by a re-suspend, snapshotting only batch 1. Under this PR the subscriber correctly stays deferred and receives batch 1 and batch 2 as a single changeset on the real resume.

I think the new behavior is more correct: a Connect() issued during a suspension should deliver nothing until the suspension truly ends. But per the repo's SemVer guidance, behavioral changes are breaking even when the signature is unchanged, so this belongs in the PR description and release notes rather than only in a deleted test.

5. Style nits

  • SuspendNotificationsFixture.UnitTests.cs has no UTF-8 BOM and uses a block-scoped namespace. Every other file in the repo uses file-scoped, including the sibling SuspendNotificationsFixture.IntegrationTests.cs added in this same PR, and the SuspendNotificationsFixture.cs it was split out of. The block scoping also re-indents all 464 lines, which hides the fact that this is a pure move and makes the diff unreviewable.
  • SuspendNotificationsFixture.IntegrationTests.cs also lacks the BOM (compare FilterFixture.DynamicPredicate.IntegrationTests.cs, which has one).
  • public sealed partial class UnitTests is declared partial with no second part.
  • SuspensionsAreThreadSafe stays in the parallelized UnitTests. It is benign (no interleaving requirement, so it cannot go intermittent the way the moved test did), but the PR's stated rationale about other concurrency tests would apply.

Comment thread src/DynamicData/Cache/ObservableCache.cs Outdated
Comment on lines +484 to +493
public void SuspendNotifications()
{
if (++_notifySuspendCount == 1)
++_notifySuspendCount;

// Signal suspension based on the subject's own state rather than the count.
// ResumeNotifications() decrements the count and emits its resume signal in
// 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
Member

Choose a reason for hiding this comment

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

Minor, but worth doing while you are in here: _areNotificationsSuspended.Value and .IsDisposed are two separate reads with no atomicity between them, so this has the same TOCTOU as the resume path (a concurrent Dispose() from the off-lock delivery callback can slip between them and make .Value throw). BehaviorSubject<T>.Value also takes the subject's own internal lock on every call.

Since every mutation of this state already happens under _locker, a plain field is cheaper, race-free, and makes the intent explicit:

private bool _isSuspendSignalled;

public void SuspendNotifications()
{
    ++_notifySuspendCount;

    // ResumeNotifications() decrements the count and emits its resume signal in
    // separate steps, so the count can briefly reach zero before the 'false' signal
    // is emitted. Gating on the signalled state keeps the signal monotonic and avoids
    // a redundant 'true' when a suspend interleaves in that window.
    if (!_isSuspendSignalled && !_areNotificationsSuspended.IsDisposed)
    {
        Debug.Assert(_pendingChanges.Count == 0, "Shouldn't be any pending values if suspend was just started");
        _isSuspendSignalled = true;
        _areNotificationsSuspended.OnNext(true);
    }
}

That reduces the subject to a pure output channel, which is all any consumer actually uses it for.

(FWIW I verified the Debug.Assert you kept is still sound under the new gate: _pendingChanges is only appended to from EnqueueChanges while the count is above zero, and the count above zero now implies the subject is already true, so the assert cannot be reached with a non-empty list.)

Comment thread src/DynamicData.Tests/Cache/SuspendNotificationsFixture.UnitTests.cs Outdated
JakenVeina and others added 2 commits July 25, 2026 14:37
…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.
…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.
@dwcullop

dwcullop commented Jul 25, 2026

Copy link
Copy Markdown
Member

You're right, they shouldn't diverge. I went back and looked at this again and I think I was patching the symptom rather than the cause.

The actual problem is that ResumeNotifications() takes the lock twice. The using block grabs it to decrement the count and queue up the pending changes, releases it, and then the resume signal grabs it again in a second acquisition. Everything is under a lock, so nothing looks wrong at a glance, but the gap between those two acquisitions is the race window. A suspend that lands in there sees a zero count and skips emitting true, and then the resume comes along and emits its false on top of it. My re-check just made the second acquisition notice it had been beaten, which is exactly the "state corruption is cool if we guard against it" thing you called out.

So rather than re-checking, don't have a second acquisition:

     private void ResumeNotifications()
     {
-        bool emitResume;
+        using var notifications = _notifications.AcquireLock();

-        using (var notifications = _notifications.AcquireLock())
-        {
-            Debug.Assert(_suspensionTracker.IsValueCreated, "Should not be Resuming Notifications without Suspend Notifications instance");
+        Debug.Assert(_suspensionTracker.IsValueCreated, "Should not be Resuming Notifications without Suspend Notifications instance");

-            (var changes, emitResume) = _suspensionTracker.Value.ResumeNotifications();
-            if (changes is not null)
-            {
-                notifications.EnqueueNext(new CacheUpdate(changes, _readerWriter.Count, ++_currentVersion));
-            }
+        var (changes, emitResume) = _suspensionTracker.Value.ResumeNotifications();
+        if (changes is not null)
+        {
+            notifications.EnqueueNext(new CacheUpdate(changes, _readerWriter.Count, ++_currentVersion));
         }

-        // Emit the resume signal after releasing the delivery scope so that
-        // accumulated changes are delivered first. Re-check the suspend count
-        // under the lock: a concurrent SuspendNotifications() may have run in the
-        // window since the count was decremented, and its state must win. Emitting
-        // the signal only when still unsuspended keeps _notifySuspendCount and the
-        // suspended-notification subject from diverging.
+        // Emit the resume signal in the same lock acquisition that cleared the suspend
+        // count, so the count and the signal can never disagree: there is no window for a
+        // concurrent SuspendNotifications() to observe one without the other. The accumulated
+        // changes are still delivered off the lock, when this scope exits. A deferred
+        // subscriber that activates on this signal snapshots the current state and skips the
+        // still-pending delivery via the version guard in CreateConnectObservable.
         if (emitResume)
         {
-            using var readLock = _notifications.AcquireReadLock();
-            if (!_suspensionTracker.Value.AreNotificationsSuspended)
-            {
-                _suspensionTracker.Value.EmitResumeNotification();
-            }
+            _suspensionTracker.Value.EmitResumeNotification();
         }
     }

The thing that makes this work, and the reason I originally thought we couldn't do it, is that ScopedAccess.Dispose() calls ExitLockAndDeliver(), which releases the lock and then drains the queue. So the enqueue and the signal both happen in one critical section, and the accumulated changes still go out with nothing held. ResumeDeliversPendingChangesWithoutHoldingTheLock still passes, so we're not trading the race for the deadlock risk that your original lock(cache._locker) version would have introduced.

One consequence worth noting: the signal now fires before the pending changes are delivered instead of after. That turns out to be fine, because a deferred subscriber that activates on the signal takes its snapshot from GetInitialUpdates() and then skips the queued delivery it already has via the HasPending / _currentDeliveryVersion guard in CreateConnectObservable. That guard is there for precisely this case.

Nice side effect: with the window actually closed instead of papered over, ResumeSignalUnderLockPreventsStaleSnapshotFromReSuspend can come back. I dropped it back into the integration fixture and it passes right alongside your StaleResumeSignalIsSuppressedByConcurrentReSuspend. I'd much rather have both than delete one that was asserting real behavior. 2443 passing locally on net9.0, and I ran the suspend fixture 8 times back to back to make sure the integration tests weren't flaky, all green at 5s.

Two small things folded into the diff above:

  • EmitResumeNotification() needs an IsDisposed check. SuspensionTracker.Dispose() runs out of the CacheUpdateObserver completion/error callbacks, and those execute off the lock during delivery, so the subject can get torn down underneath a resume on another thread. ResumeNotifications() already guards for this, that path didn't.
  • Went back to ++_notifySuspendCount == 1 for the suspend gate. Once the count and the signal can't disagree, reading the subject's .Value isn't buying anything (and it takes the subject's own internal lock on every call), plus it lets the SuspendSubject should be false for the first suspend call assert come back.

On Interlocked for the counter, I don't think it helps here. Every mutation of it already happens inside _locker, so the increment itself was never the problem. The problem was the count and the subject getting updated in two different critical sections, and making the counter atomic on its own wouldn't have closed that.

Let me know what you think.

@JakenVeina

Copy link
Copy Markdown
Collaborator Author

false can never be emitted while the count is above zero, because the re-check and the OnNext are both inside the same _locker acquisition and no other thread can mutate the count without that lock.

That's just blatantly untrue. ++_notifySuspendCount on line 486 and --_notifySuspendCount on line 508 happen underneath two separate and unsynchronized locks. The increment is inside lock(_locker) from line 166, and the decrement is inside _notifications.AcquireLock() from line 343. This was basically my point in my prior comment.

SuspendNotificationsFixture.UnitTests.cs ... uses a block-scoped namespace. Every other file in the repo uses file-scoped, including the sibling SuspendNotificationsFixture.IntegrationTests.cs added in this same PR, and the SuspendNotificationsFixture.cs it was split out of.

Fixed.

SuspensionsAreThreadSafe stays in the parallelized UnitTests. It is benign (no interleaving requirement, so it cannot go intermittent the way the moved test did), but the PR's stated rationale about other concurrency tests would apply.

Fixed.

And your new comment just popped up, while I was working this, so I'll just leave it at these two fixes.

JakenVeina and others added 2 commits July 25, 2026 15:39
…m SuspendNotificationsFixture.UnitTests to SuspendNotificationsFixture.IntegrationTests.
…le lock acquisition

ResumeNotifications() was acquiring the lock twice: once to decrement the suspend
count and queue the accumulated changes, and again to emit the resume signal. The
gap between those two acquisitions was the race window. A SuspendNotifications()
landing in it would see a zero count and skip emitting 'true', and the resume would
then emit 'false' on top of it, leaving the count and the subject in disagreement.

Emitting the signal inside the same scope that cleared the count removes the window
entirely, rather than detecting and compensating for it after the fact.

Delivery is still performed off the lock: ScopedAccess.Dispose() calls
ExitLockAndDeliver(), which releases the lock before draining the queue. The signal
now fires before the pending changes are delivered instead of after, which a deferred
subscriber handles via the existing HasPending/_currentDeliveryVersion guard in
CreateConnectObservable.

Also guard EmitResumeNotification() against a disposed subject, since
SuspensionTracker.Dispose() runs from the delivery callbacks off the lock and can
tear the subject down underneath a concurrent resume.

With the window closed, ResumeSignalUnderLockPreventsStaleSnapshotFromReSuspend is
restored and passes alongside StaleResumeSignalIsSuppressedByConcurrentReSuspend.
@dwcullop

Copy link
Copy Markdown
Member

Pushed this as bf07da5, since you said go ahead.

Kept it to the two files: the ResumeNotifications() / SuspendNotifications() / EmitResumeNotification() change described above, plus dropping ResumeSignalUnderLockPreventsStaleSnapshotFromReSuspend back into the integration fixture. I left the rest of your nits alone since you'd already handled the styling and moved SuspensionsAreThreadSafe over.

2444 passing on net9.0, and I ran the suspend fixture 5 more times against the real branch to be sure the integration tests are solid: 26 tests, 5s, green every time.

@dwcullop

Copy link
Copy Markdown
Member

They're the same lock, though. Both ObservableCache constructors hand _locker to the DeliveryQueue as its gate:

_notifications = new DeliveryQueue<CacheUpdate>(_locker, new CacheUpdateObserver(this));

So lock (_locker) on 166 and _notifications.AcquireLock() on 343 are taking the same object. That's what I was getting at in my first comment on this PR about the DQ using the same lock, which I should have been clearer about at the time.

Doesn't really change anything about the fix, though, and your instinct about it was right regardless. Two acquisitions of the same lock still leave a gap between them, and that gap is where this bit us: resume drops the count to zero and exits the scope, a suspend grabs the lock and bumps it back to one and emits a second true, then resume re-enters and puts false on top of it. Same lock, still a window.

@JakenVeina

Copy link
Copy Markdown
Collaborator Author

The thing that makes this work, and the reason I originally thought we couldn't do it, is that ScopedAccess.Dispose() calls ExitLockAndDeliver(), which releases the lock and then drains the queue. So the enqueue and the signal both happen in one critical section, and the accumulated changes still go out with nothing held.

I find this pretty misleading then, so I'd definitely like to improve that aspect of the DeliveryQueue at some point. Even if it's just as simple as putting "ReleaseLockAnd" in the name of that resume method. But I can live with it, for now.

Let me know what you think.

I think the entire concept of buffering/batching/queueing notifications within SourceCache<> needs a re-design. We have 2 competing systems trying to do the same thing, and stepping on each others' toes: the SuspendNotifications() system and the .Edit() system. But that's definitely out-of-scope. This implementation, while not ideal, looks sound.

They're the same lock, though. Both ObservableCache constructors hand _locker to the DeliveryQueue as its gate:

I actually had the thought to wonder if that was the case, and looked for it. Somehow, I missed it, anyway.

On Interlocked for the counter, I don't think it helps here. Every mutation of it already happens inside _locker, so the increment itself was never the problem.

I meant, like, in terms of the possibility that we could implement the entire suspension system without any locking at all (except for the actual delivery). If the whole system hinges on that one counter, it should be possible, in theory.

@dwcullop

Copy link
Copy Markdown
Member

Won't let me approve because I pushed a commit, but I'd approve if I could.

@JakenVeina

Copy link
Copy Markdown
Collaborator Author

Won't let me approve because I pushed a commit, but I'd approve if I could.

Oh. Fun. A deadlock.

Well, I was able to adjust the branch protection rules to allow Maintainers to bypass them, for now. It requires an explicit additional confirmation to do so, so I'm good with leaving it that way, as long as you and @RolandPheasant are as well.

@JakenVeina
JakenVeina merged commit 4f0b457 into main Jul 27, 2026
2 of 3 checks passed
@JakenVeina
JakenVeina deleted the issues/1131 branch July 27, 2026 08:36
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