Skip to content

[Bug]: (Race condition) PropertyObservable<T> emits a duplicate value #38

@dwcullop

Description

@dwcullop

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

dotnet run

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions