-
-
Notifications
You must be signed in to change notification settings - Fork 192
WhenPropertyChanged: don't drop events fired during subscribe #1111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
dwcullop
merged 18 commits into
reactivemarbles:main
from
dwcullop:bugfix/whenpropertychanged_race
Jun 15, 2026
Merged
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
7db09a8
Fix TOCTOU race in WhenPropertyChanged/WhenValueChanged
dwcullop f06f762
Deep chain: drainer-based re-walk eliminates concurrent-mutation race
dwcullop cc90ea5
Simplify deep-chain via recursive Switch composition
dwcullop 0fbcb19
Serialize deep-chain via SharedDeliveryQueue
dwcullop 8707b9c
Address PR feedback: dedup gating, exception routing, test hygiene
dwcullop 0620887
Tighten regression-test budgets for CI runners
dwcullop e2bb62d
Refactor ObservablePropertyFactory: extract Emitter and DeepChainSubs…
dwcullop 9dc38fe
Symmetric SinglePropertySubscription parallel to DeepChainSubscription
dwcullop 54a59d9
SinglePropertySubscription: route downstream OnNext throws to OnError
dwcullop 38fee59
Collapse ProcessSignal branches via shared isInitial computation
dwcullop 50b7875
Add multi-threaded torture and AutoRefresh integration tests
dwcullop 2ec2c33
Strengthen deep-chain torture invariants
dwcullop a98ab1e
Use AsAggregator in the AutoRefresh integration test
dwcullop d073972
Remove dedup; route PropertyChanged events without equality guard
dwcullop 1643e88
Address Jake's PR feedback: simplify race tests, split single-threade…
dwcullop c241cdc
Adopt Jake's exact implementation for Shallow_ConcurrentMutationDurin…
dwcullop 7f8dacd
Drop cache-side TOCTOU rationale from integration test comment
dwcullop 85213ac
Skip AutoRefresh+Filter integration tests; add dual-subscriber variant
dwcullop File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
246 changes: 246 additions & 0 deletions
246
src/DynamicData.Tests/Binding/WhenPropertyChangedRaceFixture.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,246 @@ | ||
| // Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. | ||
| // Roland Pheasant licenses this file to you under the MIT license. | ||
| // See the LICENSE file in the project root for full license information. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.ComponentModel; | ||
| using System.Linq; | ||
| using System.Reactive; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| using DynamicData.Binding; | ||
|
|
||
| using FluentAssertions; | ||
|
|
||
| using Xunit; | ||
|
|
||
| namespace DynamicData.Tests.Binding; | ||
|
|
||
| /// <summary> | ||
| /// Regression tests for the TOCTOU race in <c>WhenPropertyChanged</c> / <c>WhenValueChanged</c>. | ||
| /// The bug: <c>ObservablePropertyFactory</c> uses <c>initial.Concat(events)</c>, which subscribes | ||
| /// to the event source only AFTER the initial value is delivered. Any <c>PropertyChanged</c> | ||
| /// notification fired during that gap is silently dropped. | ||
| /// </summary> | ||
| public sealed class WhenPropertyChangedRaceFixture | ||
| { | ||
| [Fact] | ||
| public void Shallow_ConcurrentMutationDuringInitialEmit_NotDropped() | ||
| { | ||
| var model = new TestModel { Value = 10 }; | ||
| var emissions = new List<int>(); | ||
| var observerInitialReceived = new ManualResetEventSlim(false); | ||
| var observerCanContinue = new ManualResetEventSlim(false); | ||
|
|
||
| var observer = Observer.Create<PropertyValue<TestModel, int>>(pv => | ||
| { | ||
| bool isFirst; | ||
| lock (emissions) | ||
| { | ||
| isFirst = emissions.Count == 0; | ||
| emissions.Add(pv.Value); | ||
| } | ||
|
|
||
| if (isFirst) | ||
| { | ||
| // Hold the OnNext open. With the BUG, Concat is blocked here and the propertyChanged | ||
| // event handler has NOT yet been attached; a mutation now will be silently lost. | ||
| observerInitialReceived.Set(); | ||
| observerCanContinue.Wait(); | ||
| } | ||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
|
||
| // Subscribe on a worker thread so the test thread can mutate and release while Subscribe is blocked. | ||
| var subscribeTask = Task.Run(() => | ||
| model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: true).Subscribe(observer)); | ||
|
|
||
| observerInitialReceived.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("the worker thread must reach the initial emission"); | ||
|
|
||
| // Mutate while Subscribe is parked inside observer.OnNext for the initial value. | ||
| model.Value = 20; | ||
|
|
||
| observerCanContinue.Set(); | ||
| subscribeTask.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("Subscribe must complete"); | ||
|
|
||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| // Allow any concurrent OnNext delivery to settle (handler may fire on another thread). | ||
| Thread.Sleep(100); | ||
|
|
||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| lock (emissions) | ||
| { | ||
| emissions.Should().Contain(20, | ||
| "a PropertyChanged notification fired during the subscribe gap must not be dropped"); | ||
| } | ||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| [Fact] | ||
| public void Shallow_NotifyInitialFalse_StillSubscribesEventBeforeReturning() | ||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| { | ||
| var model = new TestModel { Value = 10 }; | ||
| var emissions = new List<int>(); | ||
| var subscribeCompleted = new ManualResetEventSlim(false); | ||
|
|
||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| // Subscribe on this thread; with notifyOnInitialValue: false, Subscribe should not block. | ||
| using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false) | ||
| .Subscribe(pv => | ||
| { | ||
| lock (emissions) emissions.Add(pv.Value); | ||
| }); | ||
|
|
||
| // The act of returning from Subscribe must mean the event handler is attached. | ||
| model.Value = 20; | ||
|
|
||
| Thread.Sleep(50); | ||
|
|
||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| lock (emissions) | ||
| { | ||
| emissions.Should().Equal(new[] { 20 }, "no initial value requested; first emission must be the mutation"); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public void DeepChain_ConcurrentLeafMutationDuringInitialEmit_NotDropped() | ||
| { | ||
| var parent = new ParentModel { Child = new ChildModel { Age = 10 } }; | ||
| var emissions = new List<int>(); | ||
| var observerInitialReceived = new ManualResetEventSlim(false); | ||
| var observerCanContinue = new ManualResetEventSlim(false); | ||
|
|
||
| var observer = Observer.Create<PropertyValue<ParentModel, int>>(pv => | ||
| { | ||
| bool isFirst; | ||
| lock (emissions) | ||
| { | ||
| isFirst = emissions.Count == 0; | ||
| emissions.Add(pv.Value); | ||
| } | ||
|
|
||
| if (isFirst) | ||
| { | ||
| observerInitialReceived.Set(); | ||
| observerCanContinue.Wait(); | ||
| } | ||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| }); | ||
|
|
||
| var subscribeTask = Task.Run(() => | ||
| parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true).Subscribe(observer)); | ||
|
|
||
| observerInitialReceived.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("the worker thread must reach the initial emission"); | ||
|
|
||
| // Mutate the LEAF while the worker is parked inside the initial OnNext. | ||
| parent.Child!.Age = 20; | ||
|
|
||
| observerCanContinue.Set(); | ||
| subscribeTask.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("Subscribe must complete"); | ||
| Thread.Sleep(100); | ||
|
|
||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| lock (emissions) | ||
| { | ||
| emissions.Should().Contain(20, | ||
| "a leaf PropertyChanged notification fired during the subscribe gap must not be dropped"); | ||
| } | ||
| } | ||
|
|
||
| [Fact] | ||
| public void DeepChain_RewalkRace_NewChildMutationDuringRewalkNotDropped() | ||
| { | ||
| // Setup: subscribe to parent.Child.Age, then swap parent.Child to a new instance. | ||
| // The orchestrator must re-walk the chain. During the re-walk, a mutation on the NEW | ||
| // child must not be lost in the gap between disposing the old chain notifiers and | ||
| // subscribing the new ones. | ||
| var parent = new ParentModel { Child = new ChildModel { Age = 10 } }; | ||
| var emissions = new List<int>(); | ||
| var observerAt20 = new ManualResetEventSlim(false); | ||
| var observerCanContinue = new ManualResetEventSlim(false); | ||
| var observer = Observer.Create<PropertyValue<ParentModel, int>>(pv => | ||
| { | ||
| int receivedAt; | ||
| lock (emissions) | ||
| { | ||
| emissions.Add(pv.Value); | ||
| receivedAt = emissions.Count; | ||
| } | ||
|
|
||
| // After the parent swap is delivered (the value 20 from the new child's initial state), | ||
| // hold the OnNext open to widen the re-walk window on a separate thread. | ||
| if (pv.Value == 20 && receivedAt == 2) | ||
| { | ||
| observerAt20.Set(); | ||
| observerCanContinue.Wait(); | ||
| } | ||
| }); | ||
|
|
||
| using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true).Subscribe(observer); | ||
|
|
||
| var newChild = new ChildModel { Age = 20 }; | ||
| var swapTask = Task.Run(() => parent.Child = newChild); | ||
|
|
||
| observerAt20.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("the worker thread must reach the post-swap emission"); | ||
|
|
||
| // While we are parked inside observer.OnNext for the re-walk value, mutate the NEW child. | ||
| // If the orchestrator drops events during chain re-walk, this mutation is lost. | ||
| newChild.Age = 30; | ||
|
|
||
| observerCanContinue.Set(); | ||
| swapTask.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue(); | ||
| Thread.Sleep(100); | ||
|
|
||
|
dwcullop marked this conversation as resolved.
Outdated
|
||
| lock (emissions) | ||
| { | ||
| emissions.Should().Contain(30, | ||
| "a mutation on the new child during the chain re-walk must not be dropped"); | ||
| } | ||
| } | ||
|
|
||
| private sealed class TestModel : INotifyPropertyChanged | ||
| { | ||
| private int _value; | ||
|
|
||
| public event PropertyChangedEventHandler? PropertyChanged; | ||
|
|
||
| public int Value | ||
| { | ||
| get => _value; | ||
| set | ||
| { | ||
| _value = value; | ||
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value))); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private sealed class ParentModel : INotifyPropertyChanged | ||
| { | ||
| private ChildModel? _child; | ||
|
|
||
| public event PropertyChangedEventHandler? PropertyChanged; | ||
|
|
||
| public ChildModel? Child | ||
| { | ||
| get => _child; | ||
| set | ||
| { | ||
| _child = value; | ||
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private sealed class ChildModel : INotifyPropertyChanged | ||
| { | ||
| private int _age; | ||
|
|
||
| public event PropertyChangedEventHandler? PropertyChanged; | ||
|
|
||
| public int Age | ||
| { | ||
| get => _age; | ||
| set | ||
| { | ||
| _age = value; | ||
| PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age))); | ||
| } | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.