fix: serialize PropertyObservable / PropertyChangingObservable initial-emit with concurrent handlers - #39
Merged
glennawatson merged 2 commits intoAug 2, 2026
Conversation
dwcullop
marked this pull request as ready for review
June 14, 2026 10:09
dwcullop
force-pushed
the
bugfix/propertyobservable_race
branch
from
June 14, 2026 10:19
4f6b4e7 to
d155de0
Compare
- Run the subscription's read-decision-emit sequence under a per-instance gate, shared with the change handler through a new EmitCurrent. A write from another thread landing between the handler attach and the constructor's state writes previously emitted the same value twice, breaking the no-consecutive-duplicates contract of distinctUntilChanged: true. - Apply the distinct-until-changed test to the initial emit as well, collapsing the duplicate a competing handler leaves behind when it emits first while no value has been recorded yet. The first emission on an ordinary subscribe is unaffected, since no value has been recorded at that point either. - Give PropertyChangingObservable the same shape. The out-of-order case is reachable there too: two writes passing through the constructor's read-to-emit gap deliver the initial, now stale, value after a newer one. - Cover both with forced interleavings driven from the source's event accessor, the property read and the downstream observer, so the schedules are pinned rather than raced for.
glennawatson
force-pushed
the
bugfix/propertyobservable_race
branch
from
August 2, 2026 02:26
d155de0 to
c669c43
Compare
glennawatson
approved these changes
Aug 2, 2026
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 #38.
PropertyObservable<T>.Subscriptionattaches the source'sPropertyChangedhandler before reading and emitting the initial value, but the constructor's read-state-emit sequence is not synchronized with the handler it just attached. A mutation on a background thread that firesPropertyChangedbetween the attach and the constructor's state writes produces two emissions of the same value: the racing handler sees_hasValue == false, skips the distinct check, and emits; the constructor then re-emits the same value with no dedup. WithdistinctUntilChanged: truethe downstream observer expects no two consecutive equal values; the race lets one through. A related variant lands when the mutation falls between the constructor's read and its emit, in which case the constructor delivers a stale value after the handler's fresher emit.This serializes the constructor's initial-emit and the handler's body under a shared per-instance gate, extracted into an
EmitCurrenthelper called from both the constructor andOnPropertyChanged. Any racing handler runs either fully before or fully after the constructor's emit. The distinct check now also applies on the initial emit, so the case where a racing handler emits first under the original distinct-skipping branch (taken because_hasValuewas stillfalse) no longer produces a duplicate.PropertyChangingObservable<T>receives the sameEmitCurrentpattern for shape-consistency. Thefire-PropertyChanging-then-writesemantics on a normal INPC setter make the analogous out-of-order case naturally unreachable on that type (handler runs synchronously and completes its emit before the setter writes, so the main thread can only read the new value after the handler emit is already through the downstream observer), but the gate keeps the two subscription types consistent and guards atypical user implementations.Tests
Two multi-threaded stress tests in
PropertyObservableSubscribeRaceProbeTests, each 5_000 iterations ofPropertyObservable<T>withdistinctUntilChanged: trueagainst a mutator thread writing the property 32 times.Subscribe_ConcurrentMutationDuringInitialEmit_DoesNotEmitConsecutiveDuplicateasserts the dedup contract directly: no consecutive duplicate emission ever reaches the downstream observer. Without the fix it produces roughly 100-300 iterations with a consecutive duplicate per run on my machine; with the fix it produces zero.Subscribe_AfterSettlement_LastObservedValueMatchesPropertyasserts the end-to-end invariant that the subscriber's last observed value equals the property's final value once everything has settled. The mutator does a syntheticPropertyChangedre-fire after a synchronization barrier so the assertion measures the subscription's behaviour rather than the orthogonal INPC event-accessor visibility race (the mutator'sPropertyChanged.Invokecan otherwise read a stale snapshot of the delegate that omits the just-attached handler). With the fix this test passes; the test does not by itself differentiate fixed from unfixed code (duplicate emissions don't change the final value), so it documents the invariant rather than acting as a regression probe.The 481 pre-existing
Observablestests continue to pass.