Skip to content

Commit 69586a4

Browse files
authored
Fix: WhenAnyValue subscribes to PropertyChanged before reading the initial value (#4381)
Fixes #4380. `ExpressionChainSink.Level.SetParent` reads the property and emits the kicker value before subscribing to `PropertyChanged`. Any mutation that fires `PropertyChanged` between the two is raised against an empty handler list and silently lost, leaving downstream observers pinned to the pre-write value until the next change. The race fires non-deterministically when the property can be written from a thread other than the one calling `Subscribe` (typical MVVM pattern: a view model aggregating data from background services). This swaps the order to subscribe-then-read. A racing handler emit that arrives queued behind the chain sink's gate then delivers the same post-mutation value the kicker just emitted, so a one-shot `_suppressNextIfSameAsLast` guard collapses the single racing duplicate. The guard fires independently of the user-facing `isDistinct` flag, so existing distinct semantics are unchanged. ## Scope The same defect exists in the released `23.2.28` code (via `NestedObservedChanges.StartWith(kicker)` then `Subscribe(NotifyForProperty)`), but that code path is being replaced by the custom-reactive-sinks rework on `main`. This PR fixes the defect in the rework's new location so the next release ships with the fix. Backporting to a `23.2.x` hotfix branch (if one is maintained) would be a separate change. ## Tests In `WhenAnyValueSubscribeRaceTests`: - `WhenAnyValue_MutationBetweenInitialEmitAndHandlerAttach_IsLost` — deterministic single-thread repro on `ReactiveObject`. - `..._IsLost_PlainInpc` — same shape with a hand-rolled `INotifyPropertyChanged` source, to rule out `ReactiveObject` specifics. - `WhenAnyValue_ConcurrentMutationDuringSubscribe_NeverLosesFinalValue_Stress` — 2 000-iteration multi-threaded stress (~7.5% failure rate without the fix; 0% with it). All three fail without the fix and pass with it. Full `ReactiveUI.Tests` suite (1 899 pre-existing + 3 new) passes locally on `net8.0`.
1 parent 742cb4e commit 69586a4

2 files changed

Lines changed: 245 additions & 18 deletions

File tree

‎src/ReactiveUI/Internal/ExpressionChainSink.cs‎

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ private sealed class Sink : IDisposable
8181
/// Whether holds a value yet.
8282
private bool _hasLast;
8383

84+
///
85+
/// When , the next emission that equals is suppressed
86+
/// regardless of . Set immediately after the kicker emits during
87+
/// : any PropertyChanged event that races the
88+
/// subscribe-then-read window has been queued behind and will re-emit the
89+
/// same value once we release the lock. The suppression collapses that single racing duplicate
90+
/// without altering the user-facing distinct semantics.
91+
///
92+
private bool _suppressNextIfSameAsLast;
93+
8494
/// Latched once this chain subscription has been disposed.
8595
private bool _disposed;
8696

@@ -139,47 +149,85 @@ public void Dispose()
139149
/// The value the link produced (the parent for the next level).
140150
private void SetNextParent(int level, object? value) => _levels[level + 1].SetParent(value);
141151

142-
/// Handles a leaf raw emission: applies skip-initial, the non-null-parent filter, the cast and the distinct gate.
152+
/// Handles a leaf raw emission: applies skip-initial, the non-null-parent filter, the cast, the
153+
/// kicker dedup and the distinct gate.
143154
/// Whether the leaf's parent was null (a raw emission that the non-null filter drops).
144155
/// The leaf value when the parent is present.
145-
private void Emit(bool parentMissing, object? value)
156+
/// Whether this emission is the kicker push that
157+
/// performs immediately after attaching the link subscription.
158+
private void Emit(bool parentMissing, object? value, bool fromKicker = false)
146159
{
147160
if (_skipNext)
148161
{
149162
_skipNext = false;
163+
_suppressNextIfSameAsLast = false;
150164
return;
151165
}
152166

153167
if (parentMissing)
154168
{
169+
_suppressNextIfSameAsLast = false;
155170
return;
156171
}
157172

158-
TValue typed;
159-
if (value is null)
173+
if (!TryConvert(value, out var typed))
160174
{
161-
typed = default!;
162-
}
163-
else if (value is TValue cast)
164-
{
165-
typed = cast;
166-
}
167-
else
168-
{
169-
_downstream.OnError(new InvalidCastException($"Unable to cast from {value.GetType()} to {typeof(TValue)}."));
170175
return;
171176
}
172177

173-
if (_isDistinct && _hasLast && EqualityComparer<TValue>.Default.Equals(typed, _last))
178+
if (ShouldSuppress(typed, fromKicker))
174179
{
175180
return;
176181
}
177182

178183
_last = typed;
179184
_hasLast = true;
185+
_suppressNextIfSameAsLast = fromKicker;
180186
_downstream.OnNext(new ObservedChange<TSender, TValue>(_source!, _expression, typed));
181187
}
182188

189+
/// Coerces the raw observed value into , forwarding a cast error if it
190+
/// is neither nor assignable.
191+
/// The raw observed value.
192+
/// The coerced value when conversion succeeds; the default otherwise.
193+
/// when the value was converted; when an error has
194+
/// been forwarded to the downstream observer.
195+
private bool TryConvert(object? value, out TValue typed)
196+
{
197+
if (value is null)
198+
{
199+
typed = default!;
200+
return true;
201+
}
202+
203+
if (value is TValue cast)
204+
{
205+
typed = cast;
206+
return true;
207+
}
208+
209+
_downstream.OnError(new InvalidCastException($"Unable to cast from {value.GetType()} to {typeof(TValue)}."));
210+
typed = default!;
211+
return false;
212+
}
213+
214+
/// Decides whether the coerced value should be suppressed: collapses a single racing duplicate
215+
/// produced by the subscribe-then-read window and then applies the user-facing distinct gate.
216+
/// The coerced leaf value.
217+
/// Whether this emission is the kicker push (which is never suppressed).
218+
/// when the emission must be dropped.
219+
private bool ShouldSuppress(TValue typed, bool fromKicker)
220+
{
221+
if (!fromKicker && _suppressNextIfSameAsLast && _hasLast && EqualityComparer<TValue>.Default.Equals(typed, _last))
222+
{
223+
_suppressNextIfSameAsLast = false;
224+
return true;
225+
}
226+
227+
_suppressNextIfSameAsLast = false;
228+
return _isDistinct && _hasLast && EqualityComparer<TValue>.Default.Equals(typed, _last);
229+
}
230+
183231
/// A single chain link's watcher: re-subscribes on parent change and reads the link's value.
184232
/// The owning chain sink.
185233
/// This watcher's position in the chain.
@@ -218,10 +266,14 @@ public void SetParent(object? parent)
218266

219267
var link = sink._links[index];
220268

221-
// Kicker: propagate the current value immediately, then subscribe for updates.
222-
Push(ReadValue(parent));
269+
// Subscribe-then-read: attach the link subscription before reading the kicker value so
270+
// any PropertyChanged that fires between the two steps is queued behind sink._gate
271+
// (which our caller holds) rather than silently lost. The Push that follows is marked
272+
// as the kicker so the leaf can collapse the single racing duplicate that the queued
273+
// notification will deliver once we release the lock.
223274
_subscription.Disposable = sink._notify(parent, link, sink._beforeChange, sink._suppressWarnings)
224275
.Subscribe(new Observer(this));
276+
Push(ReadValue(parent), fromKicker: true);
225277
}
226278

227279
///
@@ -274,11 +326,13 @@ public void OnNotification(IObservedChange change)
274326

275327
/// Forwards this link's value to the next level, or emits it at the leaf.
276328
/// The value this link produced.
277-
private void Push(object? value)
329+
/// Whether this push originates from the kicker performed at the end of
330+
/// (only meaningful at the leaf).
331+
private void Push(object? value, bool fromKicker = false)
278332
{
279333
if (isLeaf)
280334
{
281-
sink.Emit(parentMissing: false, value);
335+
sink.Emit(parentMissing: false, value, fromKicker);
282336
}
283337
else
284338
{
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) 2009-2026 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.ComponentModel;
7+
using System.Globalization;
8+
using ReactiveUI.Tests.ReactiveObjects.Mocks;
9+
10+
namespace ReactiveUI.Tests.WhenAny;
11+
12+
///
13+
/// Tests targeting the "missed update" race between WhenAnyValue's initial value read and the
14+
/// handler attachment.
15+
///
16+
///
17+
/// In ExpressionChainSink.Sink.Level.SetParent, the subscribing thread (1) reads the current
18+
/// property value via the cached getter and emits it downstream, then (2) subscribes to
19+
/// PropertyChanged. Any mutation that fires PropertyChanged between steps (1) and (2)
20+
/// runs against an empty subscriber list and is silently lost, leaving downstream stuck on the
21+
/// pre-mutation value until the next mutation.
22+
///
23+
public class WhenAnyValueSubscribeRaceTests
24+
{
25+
///
26+
/// Deterministically reproduces the missed-update race using a synchronous wedge: the subscriber's
27+
/// initial OnNext mutates the source property before WhenAnyValue has had a chance to
28+
/// attach its PropertyChanged handler. With the bug present, the new value is never observed.
29+
///
30+
/// A representing the asynchronous operation.
31+
[Test]
32+
public async Task WhenAnyValue_MutationBetweenInitialEmitAndHandlerAttach_IsLost()
33+
{
34+
var fixture = new TestFixture { IsOnlyOneWord = "initial" };
35+
var values = new List<string?>();
36+
37+
// Mutate during the initial emission. We are still inside ExpressionChainSink's SetParent,
38+
// between the value read (already done) and the PropertyChanged handler attachment (not
39+
// yet done). The PropertyChanged event raised by this setter has no subscriber and is lost.
40+
using var subscription = fixture.WhenAnyValue(x => x.IsOnlyOneWord).Subscribe(value =>
41+
{
42+
values.Add(value);
43+
if (values.Count != 1)
44+
{
45+
return;
46+
}
47+
48+
fixture.IsOnlyOneWord = "raced";
49+
});
50+
51+
// Sanity check: the property actually holds the racing value.
52+
await Assert.That(fixture.IsOnlyOneWord).IsEqualTo("raced");
53+
54+
// The subscriber must eventually see the racing value: either because the initial read
55+
// captured it, or because the PropertyChanged handler picked it up. With the bug, the
56+
// handler was attached after the event fired, so neither path delivered the update.
57+
await Assert.That(values).Contains("raced");
58+
}
59+
60+
///
61+
/// Same race, but using a hand-rolled source rather than
62+
/// , to confirm the bug is in the chain sink (subscriber-side) and
63+
/// not in any specifics.
64+
///
65+
/// A representing the asynchronous operation.
66+
[Test]
67+
public async Task WhenAnyValue_MutationBetweenInitialEmitAndHandlerAttach_IsLost_PlainInpc()
68+
{
69+
var notifier = new PlainInpc { Value = 1 };
70+
var values = new List<int>();
71+
72+
const int RacedValue = 2;
73+
using var subscription = notifier.WhenAnyValue(x => x.Value).Subscribe(value =>
74+
{
75+
values.Add(value);
76+
if (values.Count != 1)
77+
{
78+
return;
79+
}
80+
81+
notifier.Value = RacedValue;
82+
});
83+
84+
await Assert.That(notifier.Value).IsEqualTo(RacedValue);
85+
await Assert.That(values).Contains(RacedValue);
86+
}
87+
88+
///
89+
/// Multi-threaded stress test that proves the race condition itself, not just the underlying
90+
/// ordering defect. One mutator thread writes the property a fixed number of times in a tight
91+
/// loop while the main thread subscribes via WhenAnyValue. In every iteration, after both
92+
/// threads have finished, the subscriber's last observed value must equal the property's final
93+
/// value. Any divergence means a real PropertyChanged raised on the mutator thread fired
94+
/// during the main thread's read-then-subscribe window and was dropped on the floor.
95+
///
96+
///
97+
/// Without the fix in ExpressionChainSink.Level.SetParent, this test fails reliably (on
98+
/// the order of 5-10% of iterations drop the final mutation). With the fix, the handler is
99+
/// attached before the kicker read, the mutator's PropertyChanged invocation runs the
100+
/// handler on the mutator's thread, the handler blocks on sink._gate until the main
101+
/// thread releases it, and then re-emits the post-mutation value. Every iteration converges.
102+
///
103+
/// A representing the asynchronous operation.
104+
[Test]
105+
public async Task WhenAnyValue_ConcurrentMutationDuringSubscribe_NeverLosesFinalValue_Stress()
106+
{
107+
const int iterations = 2_000;
108+
const int mutationsPerIteration = 32;
109+
var divergences = new List<string>();
110+
111+
for (var i = 0; i < iterations; i++)
112+
{
113+
var fixture = new TestFixture { IsOnlyOneWord = "v0" };
114+
using var mutatorReady = new ManualResetEventSlim(false);
115+
using var mutatorDone = new ManualResetEventSlim(false);
116+
117+
var mutator = new Thread(() =>
118+
{
119+
mutatorReady.Set();
120+
for (var j = 1; j <= mutationsPerIteration; j++)
121+
{
122+
fixture.IsOnlyOneWord = "v" + j.ToString(CultureInfo.InvariantCulture);
123+
}
124+
125+
mutatorDone.Set();
126+
})
127+
{ IsBackground = true };
128+
mutator.Start();
129+
mutatorReady.Wait();
130+
131+
string? latest = null;
132+
using (fixture.WhenAnyValue(x => x.IsOnlyOneWord).Subscribe(v => latest = v))
133+
{
134+
mutatorDone.Wait();
135+
mutator.Join();
136+
}
137+
138+
var finalProperty = fixture.IsOnlyOneWord;
139+
if (!string.Equals(latest, finalProperty, StringComparison.Ordinal))
140+
{
141+
divergences.Add($"iter {i}: latest='{latest}' property='{finalProperty}'");
142+
}
143+
}
144+
145+
await Assert.That(divergences).IsEmpty();
146+
}
147+
148+
///
149+
/// A minimal hand-rolled source used to isolate the chain
150+
/// sink's behaviour from .
151+
///
152+
private sealed class PlainInpc : INotifyPropertyChanged
153+
{
154+
private int _value;
155+
156+
public event PropertyChangedEventHandler? PropertyChanged;
157+
158+
public int Value
159+
{
160+
get => _value;
161+
set
162+
{
163+
if (_value == value)
164+
{
165+
return;
166+
}
167+
168+
_value = value;
169+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
170+
}
171+
}
172+
}
173+
}

0 commit comments

Comments
 (0)