Skip to content

[Bug]: Cross-cache deadlock when concurrent updates notify subscribers that modify other SourceCache instances #1073

@dwcullop

Description

@dwcullop

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions