What happens
PropertyObservable<T>.Subscription attaches the PropertyChanged handler before reading and emitting the initial value. The constructor's read-state-emit sequence is not synchronized with the handler it just attached, so a mutation on a background thread that fires PropertyChanged after the attach but before the constructor has written _hasValue = true produces two emissions of the same value. With distinctUntilChanged: true the downstream observer expects no two consecutive equal values; the race lets one through.
If the racing mutation lands instead between the constructor's read and its emit, the same code path delivers a stale value to the downstream observer after the handler delivered a fresher one.
This bites any view model whose properties can be written off the subscribing thread, which is the common MVVM pattern when a property is updated by a background service.
Reproduction
https://gist.github.com/dwcullop/b9d1635275837e4e6bb665aba5b8c036
Output against the published ReactiveUI.Binding 3.4.0 package (counts vary; reliably non-zero):
Iterations: 5000
Mutations per iteration: 32
Iterations with a duplicate: 138
FAIL: PropertyObservable<T> with distinctUntilChanged=true emitted consecutive duplicates in
138 of 5000 iterations. First failing iteration: 1743.
Property's final value: 'v32'
Sequence observed: [v2, v2, v3, v4, v5, ..., v32]
The v2, v2 opening shows the racing PropertyChanged handler emitting v2 on the mutator thread and the constructor's initial emit then delivering v2 again with no dedup.
Root cause
src/ReactiveUI.Binding/Observables/PropertyObservable.cs, Subscription constructor:
public Subscription(PropertyObservable<T> parent, IObserver<T> observer)
{
_parent = parent;
_observer = observer;
_comparer = EqualityComparer<T>.Default;
parent._source.PropertyChanged += OnPropertyChanged; // (1) attach handler
// Emit initial (StartWith) value
var initial = parent._getter(parent._source); // (2) read property
_lastValue = initial; // (3) set state
_hasValue = true; // (4)
observer.OnNext(initial!); // (5) emit
}
If a background thread mutates the property after (1) but before (4), the handler runs, sees _hasValue == false, skips the distinct check on that path, emits the new value, and writes _lastValue and _hasValue. The constructor then continues, reads the property (same new value), overwrites _lastValue with the locally-captured initial, sets _hasValue to true again, and emits at (5) with no dedup.
If the mutation lands between (2) and (5) instead, initial is stale relative to what the handler just read, and (5) delivers the stale value after the handler's fresher emit.
Environment
ReactiveUI.Binding 3.4.0
- .NET 8 / 9 / 10 SDKs
- Reproduced on Windows 11; the defect is platform-independent
What happens
PropertyObservable<T>.Subscriptionattaches thePropertyChangedhandler before reading and emitting the initial value. The constructor's read-state-emit sequence is not synchronized with the handler it just attached, so a mutation on a background thread that firesPropertyChangedafter the attach but before the constructor has written_hasValue = trueproduces two emissions of the same value. WithdistinctUntilChanged: truethe downstream observer expects no two consecutive equal values; the race lets one through.If the racing mutation lands instead between the constructor's read and its emit, the same code path delivers a stale value to the downstream observer after the handler delivered a fresher one.
This bites any view model whose properties can be written off the subscribing thread, which is the common MVVM pattern when a property is updated by a background service.
Reproduction
https://gist.github.com/dwcullop/b9d1635275837e4e6bb665aba5b8c036
Output against the published
ReactiveUI.Binding 3.4.0package (counts vary; reliably non-zero):The
v2, v2opening shows the racingPropertyChangedhandler emittingv2on the mutator thread and the constructor's initial emit then deliveringv2again with no dedup.Root cause
src/ReactiveUI.Binding/Observables/PropertyObservable.cs,Subscriptionconstructor:If a background thread mutates the property after (1) but before (4), the handler runs, sees
_hasValue == false, skips the distinct check on that path, emits the new value, and writes_lastValueand_hasValue. The constructor then continues, reads the property (same new value), overwrites_lastValuewith the locally-capturedinitial, sets_hasValueto true again, and emits at (5) with no dedup.If the mutation lands between (2) and (5) instead,
initialis stale relative to what the handler just read, and (5) delivers the stale value after the handler's fresher emit.Environment
ReactiveUI.Binding3.4.0