Repository navigation
Synchronize ScanSink's accumulator to fix a data race (fixes #2679) - #2735
Open
jadhavgaurav wants to merge 1 commit into
Open
jadhavgaurav wants to merge 1 commit into
jadhavgaurav wants to merge 1 commit into
Conversation
ScanSink mutated its accumulate state directly in on(_:) with no lock, unlike every other sink with comparable cross-event mutable state (Merge, Zip, Multicast, ReplaySubject, Buffer, and others), which all guard their state with a lock. PublishSubject only synchronizes its own bookkeeping in synchronized_on; the actual delivery to observers happens in dispatch (Bag+Rx.swift) outside that lock. So two threads calling onNext concurrently on a shared subject really can invoke ScanSink.on(_:) at the same time on different threads, racing on the read-modify-write of accumulate. This is reproducible: a stress test that fires 20,000 concurrent onNext calls into a shared PublishSubject through scan() shows lost and duplicated accumulator values, and RxSwift's own debug-only SynchronizationTracker independently flags tens of thousands of "Synchronization anomaly" warnings during the same run. Under Thread Sanitizer (swift build -Xswiftc -sanitize=thread), this reliably produces a hard "Swift access race" report pointing at ScanSink.on(_:) in Scan.swift, showing two GCD worker threads modifying the same accumulate storage concurrently. Reverting only this fix (keeping the stress test) reproduces both the TSan report and the functional corruption every time; with the fix applied, repeated runs (with and without TSan) are clean. The fix mirrors the existing LockOwnerType / SynchronizedOnType pattern used by BufferTimeCountSink and other sinks with similar state: on(_:) now delegates to synchronizedOn(_:), which takes the sink's RecursiveLock before calling into the renamed synchronized_on(_:), which contains the original logic unchanged. A regression test, testScan_ConcurrentNextEvents_DoesNotCorruptAccumulator, is added to Observable+ScanTests.swift, following the concurrency test style already used in Observable+ConcurrencyTests.swift. It fires the 20,000 concurrent onNext calls described above and asserts every accumulated value from 1 to 20,000 is observed exactly once. Fixes ReactiveX#2679 Signed-off-by: Gaurav Jadhav <gaurav.vjadhav01@gmail.com>
Author
|
Friendly ping. This fixes a data race in ScanSink and includes a regression test; CI is green. Happy to adjust anything. |
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 #2679
Root cause
ScanSink<Element, Observer>.on(_:)inRxSwift/Observables/Scan.swiftmutatesself.accumulate, a plainvar, directly on every.nextevent with no lock:Every other sink with comparable cross-event mutable state (
Merge,Zip,Multicast,ReplaySubject,Buffer, and others) guards that state with a lock via theLockOwnerType/SynchronizedOnTypepattern.ScanSinkis the odd one out.This is reachable in practice, not just in theory:
PublishSubject.on(_:)only takes its lock aroundsynchronized_on, which builds a snapshot of the current observers. The actual delivery to those observers happens indispatch(_:_:)(RxSwift/Extensions/Bag+Rx.swift), outside that lock:So two threads calling
subject.onNext(_:)concurrently on a sharedPublishSubjectreally can invokeScanSink.on(_:)at the same time on two different threads, racing on the read-modify-write ofaccumulate.Reproduction
I built a stress test that subscribes
.scan(0) { acc, _ in acc + 1 }to a sharedPublishSubject<Int>and fires 20,000-50,000onNextcalls concurrently viaDispatchQueue.concurrentPerform, then checks whether every accumulated value from 1...N was observed exactly once.Without the fix, this is genuinely flaky (as data races are), but reliably catchable:
SynchronizationTracker(built intoPublishSubject, unrelated to my test) independently flags the exact same run with 50,000+ "Synchronization anomaly" warnings, confirming overlapping event delivery from multiple unsynchronized threads.swift build -Xswiftc -sanitize=thread), the race is caught on every run, with a precise report pointing at the exact line:Two different GCD worker threads modifying the same
accumulatestorage, both stacks going throughPublishSubject.on→dispatch→ScanSink.on(_:)→Scan.swift:65(thetry parent.accumulator(&accumulate, element)line).With the fix applied, the same harness is clean: 0 TSan warnings across multiple runs, 0 duplicate/lost values, deterministic pass every time.
To confirm the fix is actually what resolves it (not just a coincidence of timing), I reverted only the source change in
Scan.swift(viagit apply -Ron a saved patch, keeping the stress test) and reran under the identical conditions: the TSan race report and functional corruption both came back immediately. Reapplying the fix made them disappear again.The fix
Mirrors the existing
LockOwnerType/SynchronizedOnTypepattern already used byBufferTimeCountSink(and the same pattern used acrossMerge,Zip, etc.):on(_:)now delegates tosynchronizedOn(_:), which takes the sink'sRecursiveLockbefore calling intosynchronized_on(_:), which holds the original, unchanged logic.Test
Added
testScan_ConcurrentNextEvents_DoesNotCorruptAccumulatortoObservable+ScanTests.swift, following the concurrency-stress-test style already established inObservable+ConcurrencyTests.swift(DispatchQueue-based, real threads, not the virtualTestScheduler). It fires 20,000 concurrentonNextcalls throughscan()and asserts every value from 1 to 20,000 is observed exactly once, with no duplicates.I wasn't able to run this through
swift testor the XcodeAllTests-macOSscheme in my environment (no Xcode installation available, only Command Line Tools, so theRxTest/XCTest-linked targets can't build locally here) — this matches why the project's own CI never runsswift testeither, onlyxcodebuildagainst the Xcode schemes. I validated the equivalent logic instead via a standalone SwiftPM executable that links onlyRxSwift(no XCTest dependency), which is what produced the plain-run and TSan results above.swift build --target RxSwift(andRxRelay/RxBlocking/RxCocoa) succeed cleanly with the fix in place. I'd appreciate a maintainer running the added test through the Xcode-based CI to confirm.I wasn't able to run SwiftLint locally either (it depends on SourceKit from a full Xcode install, also unavailable here), but the change is styled to match
BufferTimeCountSinkexactly, andTests/isn't included in.swiftlint.yml's lint scope regardless.Disclosure
I used AI assistance (Claude) to help investigate this issue and prepare this PR. The root cause analysis, the reproduction, and the fix were all verified directly against RxSwift's source and by running the stress test and TSan reports shown above; I'm not asking anyone to take these claims on faith without the evidence included here.