Fixed #1131 - Race condition between SuspendNotifications() and .ResumeNotifications() - #1132
Conversation
|
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] |
There was a problem hiding this comment.
Any particular reason to move this?
dwcullop
left a comment
There was a problem hiding this comment.
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.0suite on the PR branch: 2439 passed, 0 failed, 38s. SuspendNotificationsFixturein 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-parallelIntegrationTestscollection 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:
falsecan never be emitted while the count is above zero, because the re-check and theOnNextare both inside the same_lockeracquisition and no other thread can mutate the count without that lock.falsecan never be lost, because whichever resumer observes a zero count last will emit it.truecan never be emitted twice, because the gate reads the subject's own value under_locker.- A duplicate
falseis possible when two resumers race in the window after the enqueue scope is released. It is harmless behindWhere(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.cshas no UTF-8 BOM and uses a block-scoped namespace. Every other file in the repo uses file-scoped, including the siblingSuspendNotificationsFixture.IntegrationTests.csadded in this same PR, and theSuspendNotificationsFixture.csit 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.csalso lacks the BOM (compareFilterFixture.DynamicPredicate.IntegrationTests.cs, which has one).public sealed partial class UnitTestsis declaredpartialwith no second part.SuspensionsAreThreadSafestays in the parallelizedUnitTests. 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.
| 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) |
There was a problem hiding this comment.
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.)
…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.
|
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 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 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 Nice side effect: with the window actually closed instead of papered over, Two small things folded into the diff above:
On Let me know what you think. |
That's just blatantly untrue.
Fixed.
Fixed. And your new comment just popped up, while I was working this, so I'll just leave it at these two fixes. |
…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.
|
Pushed this as Kept it to the two files: the 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. |
|
They're the same lock, though. Both _notifications = new DeliveryQueue<CacheUpdate>(_locker, new CacheUpdateObserver(this));So 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 |
I find this pretty misleading then, so I'd definitely like to improve that aspect of the
I think the entire concept of buffering/batching/queueing notifications within
I actually had the thought to wonder if that was the case, and looked for it. Somehow, I missed it, anyway.
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. |
|
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. |
Fixed that
ObservableCache.SuspendNotifications()uses alockto 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
IntegrationTestsfixture, 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 basiclock()when perhaps the proper solution should be using the locking mechanisms uponDeliveryQueue<>. But I don't have the familiarity with it. For now, I just duplicated the existinglock(_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.