Describe the bug 🐞
ObservableCache holds its internal _locker during InvokeNext, which calls _changes.OnNext synchronously while the lock is held. When a subscriber modifies a different SourceCache from inside the OnNext callback, that second cache acquires its own _locker and also calls InvokeNext under its lock. If two threads concurrently update two caches that notify into each other, the lock acquisition order inverts between threads, causing a deadlock.
This affects any scenario where SourceCache instances are wired together via Connect subscribers — including PopulateInto, MergeManyChangeSets, or any custom subscriber that writes to another cache in response to changes.
The deadlock is difficult to trigger because it requires concurrent updates to two interdependent caches with subscriber chains that cross cache boundaries.
Reproduction Test
const int iterations = 100;
for (var iter = 0; iter < iterations; iter++)
{
using var cacheA = new SourceCache<TestItem, string>(static x => x.Key);
using var cacheB = new SourceCache<TestItem, string>(static x => x.Key);
using var subA = cacheA.Connect().Subscribe(changes =>
{
foreach (var c in changes)
{
if (c.Reason == ChangeReason.Add && !c.Current.Key.StartsWith("x"))
{
cacheB.AddOrUpdate(new TestItem("x" + c.Current.Key, c.Current.Value));
}
}
});
using var subB = cacheB.Connect().Subscribe(changes =>
{
foreach (var c in changes)
{
if (c.Reason == ChangeReason.Add && !c.Current.Key.StartsWith("x"))
{
cacheA.AddOrUpdate(new TestItem("x" + c.Current.Key, c.Current.Value));
}
}
});
var barrier = new Barrier(2);
var taskA = Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < 1000; i++)
{
cacheA.AddOrUpdate(new TestItem("a" + i, "V" + i));
}
});
var taskB = Task.Run(() =>
{
barrier.SignalAndWait();
for (var i = 0; i < 1000; i++)
{
cacheB.AddOrUpdate(new TestItem("b" + i, "V" + i));
}
});
var completed = Task.WhenAll(taskA, taskB);
var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(5)));
}
private sealed record TestItem(string Key, string Value);
This test deadlocks on the current main branch.
Reproduction repository
https://gist.github.com/dwcullop/fe877f72ac7d8df4265c8affd771453d
Expected behavior
The deadlock shouldn't happen.
DynamicData Version
DynamicData 9.4.3
Describe the bug 🐞
ObservableCacheholds its internal_lockerduringInvokeNext, which calls_changes.OnNextsynchronously while the lock is held. When a subscriber modifies a differentSourceCachefrom inside theOnNextcallback, that second cache acquires its own_lockerand also callsInvokeNextunder its lock. If two threads concurrently update two caches that notify into each other, the lock acquisition order inverts between threads, causing a deadlock.This affects any scenario where
SourceCacheinstances are wired together viaConnectsubscribers — includingPopulateInto,MergeManyChangeSets, or any custom subscriber that writes to another cache in response to changes.The deadlock is difficult to trigger because it requires concurrent updates to two interdependent caches with subscriber chains that cross cache boundaries.
Reproduction Test
This test deadlocks on the current
mainbranch.Reproduction repository
https://gist.github.com/dwcullop/fe877f72ac7d8df4265c8affd771453d
Expected behavior
The deadlock shouldn't happen.
DynamicData Version
DynamicData 9.4.3