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
/// <summary>Whether <see cref="_last"/> holds a value yet.</summary>
8282
private bool _hasLast;
8383

84+
/// <summary>
85+
/// When <see langword="true"/>, the next emission that equals <see cref="_last"/> is suppressed
86+
/// regardless of <see cref="_isDistinct"/>. Set immediately after the kicker emits during
87+
/// <see cref="Level.SetParent"/>: any <c>PropertyChanged</c> event that races the
88+
/// subscribe-then-read window has been queued behind <see cref="_gate"/> 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+
/// </summary>
92+
private bool _suppressNextIfSameAsLast;
93+
8494
/// <summary>Latched once this chain subscription has been disposed.</summary>
8595
private bool _disposed;
8696

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

142-
/// <summary>Handles a leaf raw emission: applies skip-initial, the non-null-parent filter, the cast and the distinct gate.</summary>
152+
/// <summary>Handles a leaf raw emission: applies skip-initial, the non-null-parent filter, the cast, the
153+
/// kicker dedup and the distinct gate.</summary>
143154
/// <param name="parentMissing">Whether the leaf's parent was null (a raw emission that the non-null filter drops).</param>
144155
/// <param name="value">The leaf value when the parent is present.</param>
145-
private void Emit(bool parentMissing, object? value)
156+
/// <param name="fromKicker">Whether this emission is the kicker push that <see cref="Level.SetParent"/>
157+
/// performs immediately after attaching the link subscription.</param>
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+
/// <summary>Coerces the raw observed value into <typeparamref name="TValue"/>, forwarding a cast error if it
190+
/// is neither <see langword="null"/> nor assignable.</summary>
191+
/// <param name="value">The raw observed value.</param>
192+
/// <param name="typed">The coerced value when conversion succeeds; the default otherwise.</param>
193+
/// <returns><see langword="true"/> when the value was converted; <see langword="false"/> when an error has
194+
/// been forwarded to the downstream observer.</returns>
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+
/// <summary>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.</summary>
216+
/// <param name="typed">The coerced leaf value.</param>
217+
/// <param name="fromKicker">Whether this emission is the kicker push (which is never suppressed).</param>
218+
/// <returns><see langword="true"/> when the emission must be dropped.</returns>
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
/// <summary>A single chain link's watcher: re-subscribes on parent change and reads the link's value.</summary>
184232
/// <param name="sink">The owning chain sink.</param>
185233
/// <param name="index">This watcher's position in the chain.</param>
@@ -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
/// <inheritdoc/>
@@ -274,11 +326,13 @@ public void OnNotification(IObservedChange<object?, object?> change)
274326

275327
/// <summary>Forwards this link's value to the next level, or emits it at the leaf.</summary>
276328
/// <param name="value">The value this link produced.</param>
277-
private void Push(object? value)
329+
/// <param name="fromKicker">Whether this push originates from the kicker performed at the end of
330+
/// <see cref="SetParent"/> (only meaningful at the leaf).</param>
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+
/// <summary>
13+
/// Tests targeting the "missed update" race between <c>WhenAnyValue</c>'s initial value read and the
14+
/// <see cref="INotifyPropertyChanged.PropertyChanged"/> handler attachment.
15+
/// </summary>
16+
/// <remarks>
17+
/// In <c>ExpressionChainSink.Sink.Level.SetParent</c>, the subscribing thread (1) reads the current
18+
/// property value via the cached getter and emits it downstream, then (2) subscribes to
19+
/// <c>PropertyChanged</c>. Any mutation that fires <c>PropertyChanged</c> 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+
/// </remarks>
23+
public class WhenAnyValueSubscribeRaceTests
24+
{
25+
/// <summary>
26+
/// Deterministically reproduces the missed-update race using a synchronous wedge: the subscriber's
27+
/// initial <c>OnNext</c> mutates the source property before <c>WhenAnyValue</c> has had a chance to
28+
/// attach its <c>PropertyChanged</c> handler. With the bug present, the new value is never observed.
29+
/// </summary>
30+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
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+
/// <summary>
61+
/// Same race, but using a hand-rolled <see cref="INotifyPropertyChanged"/> source rather than
62+
/// <see cref="ReactiveObject"/>, to confirm the bug is in the chain sink (subscriber-side) and
63+
/// not in any <see cref="ReactiveObject"/> specifics.
64+
/// </summary>
65+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
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+
/// <summary>
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 <c>WhenAnyValue</c>. 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 <c>PropertyChanged</c> raised on the mutator thread fired
94+
/// during the main thread's read-then-subscribe window and was dropped on the floor.
95+
/// </summary>
96+
/// <remarks>
97+
/// Without the fix in <c>ExpressionChainSink.Level.SetParent</c>, 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 <c>PropertyChanged</c> invocation runs the
100+
/// handler on the mutator's thread, the handler blocks on <c>sink._gate</c> until the main
101+
/// thread releases it, and then re-emits the post-mutation value. Every iteration converges.
102+
/// </remarks>
103+
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
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+
/// <summary>
149+
/// A minimal hand-rolled <see cref="INotifyPropertyChanged"/> source used to isolate the chain
150+
/// sink's behaviour from <see cref="ReactiveObject"/>.
151+
/// </summary>
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)