Fix: WhenAnyValue subscribes to PropertyChanged before reading the initial value#4381
Merged
Merged
Conversation
…itial value ExpressionChainSink.Level.SetParent read the property and emitted the kicker value before subscribing to PropertyChanged. Any mutation that fired PropertyChanged between the two was raised against an empty handler list and silently lost, leaving downstream observers pinned to the pre-write value until the next change. Subscribe first, 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 add a one-shot "suppress next emission if equal to the kicker" guard that fires independently of the user-facing isDistinct flag. Repro tests cover deterministic single-thread and multi-threaded stress paths. Fixes reactiveui#4380
glennawatson
approved these changes
Jun 14, 2026
Contributor
|
Thanks for the PR. I got a major refactor incoming (mostly all internal) where we swap over internally to using ReactiveUI.Primitives, basically still keeps IObservable but swaps out a lot of the internals. So would like to get this fix in before that refactor. We also have a source generated version of the WhenAny pipeline in the project in ReactiveUI.Binding.SourceGenerators mind having a peak and seeing if the issue is also there @dwcullop |
Contributor
|
The fails are false negatives. Going to get your fixes in as part of the refactor. |
Contributor
Author
|
Happy to help. 😄 Let me know if you want a fix for the I'll take a look at the Binding SourceGen repo as well and see if I find any issues. |
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Fixes #4380.
ExpressionChainSink.Level.SetParentreads the property and emits the kicker value before subscribing toPropertyChanged. Any mutation that firesPropertyChangedbetween 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 callingSubscribe(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
_suppressNextIfSameAsLastguard collapses the single racing duplicate. The guard fires independently of the user-facingisDistinctflag, so existing distinct semantics are unchanged.Scope
The same defect exists in the released
23.2.28code (viaNestedObservedChanges.StartWith(kicker)thenSubscribe(NotifyForProperty)), but that code path is being replaced by the custom-reactive-sinks rework onmain. This PR fixes the defect in the rework's new location so the next release ships with the fix. Backporting to a23.2.xhotfix branch (if one is maintained) would be a separate change.Tests
In
WhenAnyValueSubscribeRaceTests:WhenAnyValue_MutationBetweenInitialEmitAndHandlerAttach_IsLost— deterministic single-thread repro onReactiveObject...._IsLost_PlainInpc— same shape with a hand-rolledINotifyPropertyChangedsource, to rule outReactiveObjectspecifics.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.Testssuite (1 899 pre-existing + 3 new) passes locally onnet8.0.