A handler of a filter change that touches the source deadlocks with a writer of the source
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 35/100
Research direction
Start with the repro in the issue, run with the join timeout to confirm the deadlock. The lock ordering lives in AttachFilter and ResetFilter of ObservableList<T>.View<TView>, which raise Reset inside lock (SyncRoot), and in SourceCollectionChanged, which takes the view lock inside the source lock. The same pattern appears in the other five view types. Done means the repro completes, or the no-touch rule is documented, and the choice is applied consistently. The fix depends on #125 and needs a maintainer decision first.
Written by the indexing model from the issue text.
Description
Summary
AttachFilter and ResetFilter of a view raise the Reset of the filter change while they hold the lock of the view only. A change of the source takes the lock of the source first, and then the lock of the view. When a handler of that Reset touches the source while another thread writes the source, the two threads take the two locks in opposite orders and deadlock.
Reading the source is enough, because ObservableList<T>.Count and its indexer take the lock of the source. Writing through a writable view list (notify[0] = ...) takes it as well.
Repro
using System.Collections.Specialized;
using ObservableCollections;
var list = new ObservableList<int>(new[] { 1, 2, 3 });
var sourceLockHeld = new ManualResetEventSlim();
// subscribed before the view, so it runs inside the lock of the source before the view handles the change
list.CollectionChanged += (in NotifyCollectionChangedEventArgs<int> e) => sourceLockHeld.Set();
var view = list.CreateView(x => x);
var notify = view.ToNotifyCollectionChanged();
notify.CollectionChanged += (_, e) =>
{
if (e.Action != NotifyCollectionChangedAction.Reset) return;
// another thread writes the source meanwhile
new Thread(() => list.Add(4)) { IsBackground = true }.Start();
sourceLockHeld.Wait();
_ = list.Count;
};
var filter = new Thread(() => view.AttachFilter(x => x > 1)) { IsBackground = true };
filter.Start();
Console.WriteLine(filter.Join(TimeSpan.FromSeconds(3)) ? "completed" : "deadlocked");
// deadlocked
Replacing _ = list.Count with notify[0] = 10, on a list from list.CreateWritableView(x => x).ToWritableNotifyCollectionChanged(), deadlocks the same way.
Both were observed on master (a78ba19) and on the branch of #125 (7f9dc08). The ManualResetEventSlim only makes the timing deterministic; without it, the same deadlock happens whenever a writer of the source comes in while the handler runs.
Cause
AttachFilter raises the Reset inside the lock of the view (ObservableList<T>.View<TView>; ResetFilter is the same):
lock (SyncRoot)
{
this.filter = filter;
// ...
ViewChanged?.Invoke(new SynchronizedViewChangedEventArgs<T, TView>(NotifyCollectionChangedAction.Reset, true));
}
The source raises a change inside its own lock, and the view takes its lock inside that:
// ObservableList<T>.Add
lock (SyncRoot)
{
// ...
CollectionChanged?.Invoke(NotifyCollectionChangedEventArgs<T>.Add(item, index));
}
// ObservableList<T>.View<TView>.SourceCollectionChanged
lock (SyncRoot)
{
// ...
}
So the two threads take the locks in these orders:
- the thread that changes the filter: the lock of the view, then the lock of the source (in the handler);
- the thread that writes the source: the lock of the source, then the lock of the view.
The views of ObservableDictionary, ObservableHashSet, ObservableList, ObservableQueue, ObservableRingBuffer and ObservableStack all have the same AttachFilter and ResetFilter.
Suggested fix
Take the lock of the source before the lock of the view in AttachFilter and ResetFilter, so that every path takes the lock of the source first. The constructor of the view already takes the lock of the source. A writer of the source already waits for the lock of the view while a filter change runs, so the extra wait falls mainly on the readers of the source.
On master, this alone does not make every path take the lock of the source first. The writes of a writable view (SetToSourceCollection and the other *SourceCollection methods of ObservableList<T>.View<TView>) take the lock of the view before the lock of the source. #125 removes that lock from them.
If that is not wanted, document that a handler of the notification of a filter change must not touch the source.
- Dominant language
- C#
- Stars
- 1k
- Forks
- 73
- Avg merge
- 4d 18h
- Merged PRs (30d)
- 1
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from Cysharp/ObservableCollections
-
Difficulty 4/5 3-5 days Newbie friendliness 55/100
Cysharp/ObservableCollections#146 ·
-
Difficulty 3/5 Half a day Newbie friendliness 45/100
Cysharp/ObservableCollections#144 ·
-
AlternateIndexList breaks its ordering or removes the wrong elements without reporting an errorOpen
Difficulty 3/5 1-2 days Newbie friendliness 50/100
Cysharp/ObservableCollections#143 ·
-
Difficulty 4/5 1-2 days Newbie friendliness 48/100
Cysharp/ObservableCollections#142 · 1 comment ·
-
Difficulty 4/5 1-2 days Newbie friendliness 54/100
Cysharp/ObservableCollections#141 · 2 comments ·
All issues in Cysharp/ObservableCollections
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
BEagle1984/silverback#280 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 63/100
-
Playground selection bar: comes back after Apply or scrolling while focus is in the Markdown paneOpen
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
sillsdev/SIL.TextEditing#175 ·
Maintainers usually reply within 1 day
-
needs attention untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet#3666 ·
Maintainers usually reply within 3 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 73/100
MicrosoftLearning/eShopOnWeb#466 ·