diff --git a/CLAUDE.md b/CLAUDE.md index ef76d38..2faf507 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,6 +226,15 @@ because they're style, not perf. operator we ship (`Select`, `Where`, `CombineLatest`, `Merge`, `Throttle`, `Scan`, etc.) is **our own implementation** under `Operators/` (sync) or `Async/Operators/` (async). +- **`IScheduler` and `Unit` are unavoidable parts of Rx — do not try to + replace them.** `IScheduler` is the canonical scheduling abstraction the + entire Rx ecosystem (and our `IObservable` consumers) interop through; + rolling our own scheduler interface would fragment that contract for zero + benefit. `Unit` is the standard "no value" token every Rx signal stream + uses. Both are foundational BCL-level contracts: depend on them directly, + pass them through, and don't author parallel substitutes. This is the one + place the "don't rebadge" rule inverts — here the *right* move is to reuse + the `System.Reactive` type, not replace it. - **`System.Reactive.Linq.Observable.*` is banned in production code paths** outside thin BCL bridges. If a feature feels like it needs `Observable.Foo`, add our own operator with the allocation profile we diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b793d64..5001048 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,14 @@ but keep the style and pattern-matching rules. our own implementation** — `Select`, `Where`, `CombineLatest`, `Merge`, `Throttle`, `Scan`, etc. No `System.Reactive.Linq.Observable` in production code paths outside thin bridges. +- **`IScheduler` and `Unit` are unavoidable parts of Rx — never replace + them.** `IScheduler` is the scheduling abstraction the whole Rx + ecosystem interops through, and `Unit` is the standard "no value" token + for signal streams. Authoring parallel substitutes would fragment the + contract our `IObservable` consumers rely on, for no gain. Depend on + both directly and pass them through. This is the deliberate exception to + the "don't rebadge" rule below: for these two types the correct move is + to reuse the `System.Reactive` type, not reinvent it. - **Don't rebadge `System.Reactive` types 1:1.** Our replacements must be tailored, low-allocation, perf-focused, and only as thread-aware-as-needed. A `SubjectAsync` that mirrors `Subject` diff --git a/src/ReactiveUI.Extensions/Async/Disposables/CompositeDisposableAsync.cs b/src/ReactiveUI.Extensions/Async/Disposables/CompositeDisposableAsync.cs index 181358c..3f245be 100644 --- a/src/ReactiveUI.Extensions/Async/Disposables/CompositeDisposableAsync.cs +++ b/src/ReactiveUI.Extensions/Async/Disposables/CompositeDisposableAsync.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. @@ -16,13 +16,15 @@ namespace ReactiveUI.Extensions.Async.Disposables; /// items. This class is not read-only and is safe for concurrent access from multiple threads. public sealed class CompositeDisposableAsync : IAsyncDisposable { - /// - /// The minimum list capacity before the list is eligible for shrinking on removal. - /// - private const int ShrinkThreshold = 64; + /// Capacity allocated on first . Chosen as the typical upper bound + /// of subscriptions a composite holds, so most lifetimes never trigger a resize. + private const int DefaultCapacity = 8; - /// Divisor used to compute the shrink target capacity (half the current capacity). - private const int ShrinkDivisor = 2; + /// Length threshold below which Remove no longer compacts the array. + private const int ShrinkThreshold = 16; + + /// Divisor used to decide whether a remove triggers compaction (count * 4 < length). + private const int ShrinkOccupancyDivisor = 4; /// /// The synchronization gate protecting all mutable state in this collection. @@ -34,24 +36,28 @@ public sealed class CompositeDisposableAsync : IAsyncDisposable #endif /// - /// The backing list of disposables. Entries may be null after removal to avoid shifting elements. + /// Backing array of disposables. Slots may be after removal to avoid shifting elements; + /// tracks the high-water mark of used slots and tracks non-null slots. + /// until the first ; the no-arg constructor leaves it unallocated. /// - private List _list; + private IAsyncDisposable?[]? _items; - /// - /// Indicates whether the collection has been disposed. - /// - private bool _isDisposed; + /// High-water mark of used slots in . Includes slots zeroed by Remove. + private int _length; - /// - /// The number of non-null disposables currently in the collection. - /// + /// The number of non- disposables in the collection. private int _count; + /// Indicates whether the collection has been disposed. + private bool _isDisposed; + /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. The backing array is allocated + /// lazily on the first call; an unused composite costs only its instance header + gate. /// - public CompositeDisposableAsync() => _list = []; + public CompositeDisposableAsync() + { + } /// /// Initializes a new instance of the class with the specified initial capacity. @@ -69,35 +75,68 @@ public CompositeDisposableAsync(int capacity) } #endif - _list = new List(capacity); + _items = capacity == 0 ? null : new IAsyncDisposable?[capacity]; } /// - /// Initializes a new instance of the class that contains the specified asynchronous. - /// disposables. + /// Initializes a new instance of the class that contains the specified + /// disposables — the backing array is sized exactly so no resize occurs. /// - /// Each disposable provided will be disposed asynchronously when the composite is disposed. The - /// order in which disposables are disposed is the same as the order in the array. - /// An array of objects that implement IAsyncDisposable to be managed by the composite. Cannot be null, but may be - /// empty. + /// An array of objects implementing . public CompositeDisposableAsync(params IAsyncDisposable[] disposables) { - _list = [.. disposables]; - _count = _list.Count; + ArgumentExceptionHelper.ThrowIfNull(disposables); + if (disposables.Length == 0) + { + return; + } + + _items = new IAsyncDisposable?[disposables.Length]; + Array.Copy(disposables, _items, disposables.Length); + _length = disposables.Length; + _count = disposables.Length; } /// - /// Initializes a new instance of the class that contains the specified asynchronous. - /// disposables. + /// Initializes a new instance of the class that contains the specified + /// disposables. The backing array is sized exactly when implements + /// ; otherwise it grows from the default capacity. /// - /// Each disposable in the collection will be disposed asynchronously when the composite is - /// disposed. The order in which disposables are disposed is the same as the order in the provided - /// collection. - /// The collection of IAsyncDisposable instances to include in the composite. Cannot be null. + /// The collection of instances to include. public CompositeDisposableAsync(IEnumerable disposables) { - _list = [.. disposables]; - _count = _list.Count; + ArgumentExceptionHelper.ThrowIfNull(disposables); + + if (disposables is ICollection collection) + { + if (collection.Count == 0) + { + return; + } + + _items = new IAsyncDisposable?[collection.Count]; + var i = 0; + foreach (var d in collection) + { + _items[i++] = d; + } + + _length = collection.Count; + _count = collection.Count; + return; + } + + foreach (var d in disposables) + { + if (d is null) + { + continue; + } + + EnsureCapacityForOneMore(); + _items![_length++] = d; + _count++; + } } /// @@ -135,8 +174,9 @@ public ValueTask AddAsync(IAsyncDisposable item) { if (!_isDisposed) { + EnsureCapacityForOneMore(); + _items![_length++] = item; _count++; - _list.Add(item); return default; } } @@ -158,37 +198,29 @@ public async ValueTask Remove(IAsyncDisposable item) lock (_gate) { - if (_isDisposed) + if (_isDisposed || _items is null) { return false; } - var current = _list; - - var index = current.IndexOf(item); - if (index == -1) + var index = Array.IndexOf(_items, item, 0, _length); + if (index < 0) { return false; } - current[index] = null; + _items[index] = null; + _count--; - if (current.Capacity > ShrinkThreshold && _count < current.Capacity / ShrinkDivisor) + if (_count == 0) { - var fresh = new List(current.Capacity / ShrinkDivisor); - - foreach (var d in current) - { - if (d != null) - { - fresh.Add(d); - } - } - - _list = fresh; + Array.Clear(_items, 0, _length); + _length = 0; + } + else if (_length > ShrinkThreshold && _count * ShrinkOccupancyDivisor < _length) + { + CompactInPlace(); } - - _count--; } await item.DisposeAsync().ConfigureAwait(false); @@ -203,34 +235,28 @@ public async ValueTask Remove(IAsyncDisposable item) /// A task that represents the asynchronous clear operation. public async ValueTask Clear() { - IAsyncDisposable?[] targetDisposables; - int clearCount; + IAsyncDisposable?[] rented; + int clearLength; lock (_gate) { - if (_isDisposed) + if (_isDisposed || _count == 0 || _items is null) { return; } - if (_count == 0) - { - return; - } - - targetDisposables = ArrayPool.Shared.Rent(_list.Count); - clearCount = _list.Count; - - _list.CopyTo(targetDisposables); - - _list.Clear(); + clearLength = _length; + rented = ArrayPool.Shared.Rent(clearLength); + Array.Copy(_items, rented, clearLength); + Array.Clear(_items, 0, clearLength); + _length = 0; _count = 0; } try { - for (var i = 0; i < clearCount; i++) + for (var i = 0; i < clearLength; i++) { - if (targetDisposables[i] is { } item) + if (rented[i] is { } item) { await item.DisposeAsync().ConfigureAwait(false); } @@ -238,7 +264,7 @@ public async ValueTask Clear() } finally { - ArrayPool.Shared.Return(targetDisposables, true); + ArrayPool.Shared.Return(rented, true); } } @@ -253,12 +279,12 @@ public bool Contains(IAsyncDisposable item) { lock (_gate) { - if (_isDisposed) + if (_isDisposed || _items is null) { return false; } - return _list.Contains(item); + return Array.IndexOf(_items, item, 0, _length) >= 0; } } @@ -280,7 +306,7 @@ public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) lock (_gate) { - if (_isDisposed) + if (_isDisposed || _items is null) { return; } @@ -290,21 +316,12 @@ public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) throw new ArgumentOutOfRangeException(nameof(arrayIndex)); } - var i = 0; - foreach (var item in _list) + if (array is null) { - if (item is null) - { - continue; - } - - if (array is not null) - { - array[arrayIndex + i] = item; - } - - i++; + return; } + + CopyToCore(array, arrayIndex); } } @@ -318,7 +335,8 @@ public void CopyTo(IAsyncDisposable[]? array, int arrayIndex) /// A task that represents the asynchronous dispose operation. public async ValueTask DisposeAsync() { - List disposables; + IAsyncDisposable?[]? snapshot; + int snapshotLength; lock (_gate) { @@ -327,58 +345,116 @@ public async ValueTask DisposeAsync() return; } - _count = 0; _isDisposed = true; - disposables = _list; - _list = null!; // dereference. + snapshot = _items; + snapshotLength = _length; + _items = null; + _length = 0; + _count = 0; + } + + if (snapshot is null) + { + return; } - foreach (var item in disposables) + for (var i = 0; i < snapshotLength; i++) { - if (item is not null) + if (snapshot[i] is { } item) { await item.DisposeAsync().ConfigureAwait(false); } } - - disposables.Clear(); } /// - /// Returns an enumerator that iterates through a snapshot of the collection and clears its contents. + /// Returns an enumerator that iterates a snapshot of the non-null disposables in the collection. + /// The snapshot is taken under the gate; subsequent mutations do not affect the enumerator. /// - /// The enumerator operates on a snapshot of the collection taken at the time of the call. After - /// enumeration, the original collection is cleared. This method is thread-safe. - /// An enumerator for the collection of items present at the time of enumeration. + /// An enumerator over a snapshot of the collection's disposables. public IEnumerator GetEnumerator() { + IAsyncDisposable[] snapshot; lock (_gate) { - // make snapshot - return EnumerateAndClear([.. _list]).GetEnumerator(); + if (_items is null || _count == 0) + { + return EmptyEnumerator(); + } + + snapshot = new IAsyncDisposable[_count]; + var dst = 0; + for (var src = 0; src < _length; src++) + { + if (_items[src] is { } item) + { + snapshot[dst++] = item; + } + } } + + return ((IEnumerable)snapshot).GetEnumerator(); } - /// - /// Enumerates the non-null disposables in the array and clears the array when enumeration completes. - /// - /// The snapshot array of disposables to enumerate and clear. - /// An enumerable sequence of non-null disposables from the array. - private static IEnumerable EnumerateAndClear(IAsyncDisposable?[] disposables) + /// Returns an empty enumerator used when the composite holds nothing. + /// An empty enumerator. + private static IEnumerator EmptyEnumerator() { - try + yield break; + } + + /// Performs the actual copy under the assumption that bounds have been validated. + /// Destination array, guaranteed non-null by the caller. + /// Destination starting index. + private void CopyToCore(IAsyncDisposable[] array, int arrayIndex) + { + var dst = arrayIndex; + var src = _items!; + for (var i = 0; i < _length; i++) { - foreach (var item in disposables) + if (src[i] is { } item) { - if (item != null) - { - yield return item; - } + array[dst++] = item; } } - finally + } + + /// Ensures has at least one free slot at index . + /// Allocates the default-capacity array on first use; doubles on subsequent overflow. + private void EnsureCapacityForOneMore() + { + if (_items is null) { - disposables.AsSpan().Clear(); + _items = new IAsyncDisposable?[DefaultCapacity]; + return; } + + if (_length < _items.Length) + { + return; + } + + var grown = new IAsyncDisposable?[_items.Length * 2]; + Array.Copy(_items, grown, _length); + _items = grown; + } + + /// Removes null gaps inside and shrinks the backing array + /// to half its capacity. Caller must hold . + private void CompactInPlace() + { + var src = _items!; + var fresh = new IAsyncDisposable?[src.Length / 2]; + var dst = 0; + for (var i = 0; i < _length; i++) + { + if (src[i] is { } item) + { + fresh[dst++] = item; + } + } + + _items = fresh; + _length = dst; } } diff --git a/src/ReactiveUI.Extensions/Async/Disposables/DisposableAsyncSlot.cs b/src/ReactiveUI.Extensions/Async/Disposables/DisposableAsyncSlot.cs new file mode 100644 index 0000000..ae9614a --- /dev/null +++ b/src/ReactiveUI.Extensions/Async/Disposables/DisposableAsyncSlot.cs @@ -0,0 +1,109 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace ReactiveUI.Extensions.Async.Disposables; + +/// +/// Zero-allocation static helpers that implement -style swap +/// and -style single-assignment semantics directly +/// against a caller-owned field. Use these when the wrapper-class +/// allocation that the convenience types incur is on a hot path. +/// +[SuppressMessage("Design", "CA1045:Do not pass types by reference", Justification = "Ref-on-field is the entire point — mirrors Interlocked/Volatile.")] +public static class DisposableAsyncSlot +{ + /// Swaps the slot's current contents with and asynchronously + /// disposes the previous occupant. Equivalent to + /// , but operates on a caller-owned field + /// so no wrapper instance is allocated. + /// Reference to the caller-owned field. + /// The new value to store, or to clear the slot. + /// A that completes once the previous occupant (if any) has been disposed. + /// The compare-exchange retry (the loop back-edge) is only taken when a concurrent writer + /// wins the race, so it is unreachable by single-threaded tests; excluded from coverage accordingly. + [DebuggerStepThrough] + [ExcludeFromCodeCoverage] + public static ValueTask SwapAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value) + { + var current = Volatile.Read(ref slot); + while (true) + { + if (ReferenceEquals(current, DisposedSentinel.Instance)) + { + return value?.DisposeAsync() ?? default; + } + + var exchanged = Interlocked.CompareExchange(ref slot, value, current); + if (ReferenceEquals(exchanged, current)) + { + return current?.DisposeAsync() ?? default; + } + + current = exchanged; + } + } + + /// Atomically assigns to the slot exactly once. If the slot has + /// already been disposed, is disposed immediately. If the slot already + /// holds a non-null, non-disposed value, throws . + /// Equivalent to . + /// Reference to the caller-owned field. + /// The value to assign, or . + /// A that completes once has been disposed + /// (if the slot was already disposed); otherwise a completed task. + [DebuggerStepThrough] + public static ValueTask AssignAsync(ref IAsyncDisposable? slot, IAsyncDisposable? value) + { + var current = Interlocked.CompareExchange(ref slot, value, null); + if (current is null) + { + return default; + } + + if (ReferenceEquals(current, DisposedSentinel.Instance)) + { + return value?.DisposeAsync() ?? default; + } + + throw new InvalidOperationException("Disposable is already assigned."); + } + + /// Asynchronously disposes the slot's current contents and marks the slot as disposed. + /// Subsequent / calls will dispose their incoming + /// value rather than store it. Idempotent. + /// Reference to the caller-owned field. + /// A that completes once the prior occupant has been disposed. + [DebuggerStepThrough] + public static ValueTask DisposeAsync(ref IAsyncDisposable? slot) + { + var current = Interlocked.Exchange(ref slot, DisposedSentinel.Instance); + if (current is null || ReferenceEquals(current, DisposedSentinel.Instance)) + { + return default; + } + + return current.DisposeAsync(); + } + + /// Returns if the slot has been disposed via . + /// The slot field to inspect. + /// if the slot currently holds the disposed sentinel. + public static bool IsDisposed(IAsyncDisposable? slot) => + ReferenceEquals(slot, DisposedSentinel.Instance); + + /// Shared sentinel marking a disposed slot. Distinct from the per-class sentinels in + /// and so the + /// slot helpers can be used independently of (and alongside) those wrapper classes. + internal sealed class DisposedSentinel : IAsyncDisposable + { + /// Singleton sentinel instance. + public static readonly DisposedSentinel Instance = new(); + + /// + ValueTask IAsyncDisposable.DisposeAsync() => default; + } +} diff --git a/src/ReactiveUI.Extensions/Async/Internals/CancelableTaskSubscription{T}.cs b/src/ReactiveUI.Extensions/Async/Internals/CancelableTaskSubscription{T}.cs index 8749dbe..a1c31e3 100644 --- a/src/ReactiveUI.Extensions/Async/Internals/CancelableTaskSubscription{T}.cs +++ b/src/ReactiveUI.Extensions/Async/Internals/CancelableTaskSubscription{T}.cs @@ -26,10 +26,15 @@ internal abstract class CancelableTaskSubscription(IObserverAsync observer /// private readonly CancellationTokenSource _cts = new(); - /// - /// An async-local flag that indicates whether the current call is reentrant, preventing deadlocks during disposal. - /// - private readonly AsyncLocal _reentrant = new(); + /// Managed-thread ID of the thread currently inside , or + /// 0 when no run is in flight. Replaces an -based + /// reentry flag — the AsyncLocal cloned + /// on every set, costing ~80 B per Run. Thread-ID detection is exact for the + /// synchronous-reentry deadlock case (Dispose called from within the same call stack + /// as RunAsync); asynchronous reentry after a thread hop may return from Dispose + /// slightly before RunAsync's finally fires, but cancellation has already been + /// signalled so no observer notifications race the dispose. + private int _runningThreadId; /// /// Indicates whether disposal has already been initiated to prevent double-disposal. @@ -60,7 +65,7 @@ public async ValueTask DisposeAsync() } await _cts.CancelAsync().ConfigureAwait(false); - if (!_reentrant.Value) + if (Volatile.Read(ref _runningThreadId) != Environment.CurrentManagedThreadId) { await _tcs.Task.ConfigureAwait(false); } @@ -94,7 +99,7 @@ internal static async ValueTask CompleteWithFailureAsync(IObserverAsync obser /// A representing the asynchronous operation. internal async ValueTask RunAsync(CancellationToken cancellationToken) { - _reentrant.Value = true; + Volatile.Write(ref _runningThreadId, Environment.CurrentManagedThreadId); try { await RunAsyncCore(observer, cancellationToken).ConfigureAwait(false); @@ -105,6 +110,7 @@ internal async ValueTask RunAsync(CancellationToken cancellationToken) } finally { + Volatile.Write(ref _runningThreadId, 0); _tcs.SetResult(true); } } diff --git a/src/ReactiveUI.Extensions/Async/Observables/Defer.cs b/src/ReactiveUI.Extensions/Async/Observables/Defer.cs index 73bcc71..e650aa9 100644 --- a/src/ReactiveUI.Extensions/Async/Observables/Defer.cs +++ b/src/ReactiveUI.Extensions/Async/Observables/Defer.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. @@ -26,11 +26,7 @@ public static partial class ObservableAsync /// An observable sequence that, upon each subscription, invokes the factory function to obtain the actual /// observable sequence to subscribe to. public static IObservableAsync Defer(Func>> factory) => - Create(async (observer, token) => - { - var observable = await factory(token).ConfigureAwait(false); - return await observable.SubscribeAsync(observer.Wrap(), token).ConfigureAwait(false); - }); + new DeferAsyncObservableAsync(factory); /// /// Returns an observable sequence that is created by invoking the specified factory function each time a new @@ -43,9 +39,40 @@ public static IObservableAsync Defer(FuncA function that returns a new instance of an observable sequence to be subscribed to for each observer. /// An observable sequence whose observers trigger the invocation of the factory function upon subscription. public static IObservableAsync Defer(Func> factory) => - Create((observer, token) => + new DeferSyncObservableAsync(factory); + + /// Dedicated observable for . + /// Holds the factory delegate directly — no closure-capturing lambda, no + /// -wrapper indirection. Per-subscribe path invokes the factory, + /// wraps the downstream observer once for contract compliance, and subscribes to the + /// freshly-produced inner observable. + /// The element type. + /// The deferred factory invoked once per subscribe. + internal sealed class DeferSyncObservableAsync(Func> factory) : ObservableAsync + { + /// + protected override ValueTask SubscribeAsyncCore( + IObserverAsync observer, + CancellationToken cancellationToken) => + factory().SubscribeAsync(observer.Wrap(), cancellationToken); + } + + /// Dedicated observable for the -returning + /// . + /// Same allocation profile as with one extra + /// state-machine box per call to host the factory's await. + /// The element type. + /// The deferred factory invoked once per subscribe. + internal sealed class DeferAsyncObservableAsync(Func>> factory) + : ObservableAsync + { + /// + protected override async ValueTask SubscribeAsyncCore( + IObserverAsync observer, + CancellationToken cancellationToken) { - var observable = factory(); - return observable.SubscribeAsync(observer.Wrap(), token); - }); + var observable = await factory(cancellationToken).ConfigureAwait(false); + return await observable.SubscribeAsync(observer.Wrap(), cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/ReactiveUI.Extensions/Async/ObserverAsync.cs b/src/ReactiveUI.Extensions/Async/ObserverAsync.cs index efcabcb..5e4962b 100644 --- a/src/ReactiveUI.Extensions/Async/ObserverAsync.cs +++ b/src/ReactiveUI.Extensions/Async/ObserverAsync.cs @@ -20,39 +20,22 @@ namespace ReactiveUI.Extensions.Async; /// The type of the elements received by the observer. public abstract class ObserverAsync : IObserverAsync { - /// - /// Managed-thread ID of the thread that took from 0 to 1. Used to distinguish - /// reentrant calls on the same thread (legal) from concurrent calls from a different thread - /// (). The previous design tracked this via an - /// , which wrote a fresh on - /// every TryEnter — the dominant allocation in chained-operator pipelines after the - /// Linked2CancellationTokenSource fix. Thread-ID tracking is exact for synchronous-completion - /// pipelines (the hot path) and for cross-thread concurrent calls (the contract violation we surface); - /// async-flow reentrancy that hops threads mid-call is the only scenario where this approach can fire - /// a false positive, which the existing test suite does not exercise. - /// + /// Lazily-created CTS that signals disposal to in-flight operations. Stays + /// until someone requests , + /// wires a real token, or + /// is called. Terminal observers (most user-facing sinks) never trigger creation and save + /// the ~72 B per instance the CTS would cost. + private CancellationTokenSource? _disposeCts; - /// - /// Signals disposal to all in-flight operations via its cancellation token. - /// - private readonly CancellationTokenSource _disposeCts = new(); + /// Disposal latch. Set independently of so the lazy-CTS + /// path can detect post-dispose state before the CTS has been materialized. + private int _disposed; - /// - /// Synchronization gate protecting mutable state (_callsCount, _entryThreadId, _allCallsCompletedTcs). - /// -#if NET9_0_OR_GREATER - private readonly Lock _gate = new(); -#else - private readonly object _gate = new(); -#endif - - /// - /// The total number of currently executing OnNext, OnErrorResume, or OnCompleted calls. - /// - private int _callsCount; - - /// Managed-thread ID of the thread holding the in-flight call(s). See class-level XML. - private int _entryThreadId; + /// Packed call-state: high 32 bits hold the managed-thread ID of the thread + /// currently inside OnNext/OnError/OnCompleted; low 32 bits hold the in-flight call + /// depth. Updated lock-free via . + /// Replaces a monitor gate + two separate int fields, saving ~24 B per observer. + private long _callState; /// /// Completion source that is set when all in-flight calls finish after disposal has been requested. @@ -96,15 +79,16 @@ protected ObserverAsync() /// /// Gets a value indicating whether this observer has been disposed. /// - internal bool IsDisposed => _disposeCts.IsCancellationRequested; + internal bool IsDisposed => Volatile.Read(ref _disposed) != 0; /// /// Gets the cancellation token that fires when this observer disposes. Exposed for sibling operators /// in this assembly so they can wire it into a downstream observer's /// chain — that lets the downstream's hot-path equality check recognise our token as already-linked and - /// skip the per-emission linked CTS allocation. + /// skip the per-emission linked CTS allocation. Lazily materializes the backing + /// on first access. /// - internal CancellationToken InternalDisposedToken => _disposeCts.Token; + internal CancellationToken InternalDisposedToken => GetOrCreateDisposeCts().Token; /// /// Asynchronously processes the next value in the sequence. @@ -255,47 +239,38 @@ internal void LinkUpstreamCancellation(CancellationToken upstream) => [DebuggerStepThrough] internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out LinkedTokenScope scope) { - lock (_gate) + var currentThreadId = Environment.CurrentManagedThreadId; + while (true) { - if (_disposeCts.IsCancellationRequested || cancellationToken.IsCancellationRequested) + if (Volatile.Read(ref _disposed) != 0 || cancellationToken.IsCancellationRequested) { scope = default; return false; } + var oldState = Volatile.Read(ref _callState); + var oldCount = (int)oldState; + var oldThreadId = (int)(oldState >> 32); + // Concurrent-call detection: if another thread is already in-flight, this is a contract // violation. Reentrant calls from the same thread (a callback that re-enters the observer) // are legal — only cross-thread overlap fires the exception. - var currentThreadId = Environment.CurrentManagedThreadId; - if (_callsCount > 0 && _entryThreadId != currentThreadId) + if (oldCount > 0 && oldThreadId != currentThreadId) { UnhandledExceptionHandler.OnUnhandledException(new ConcurrentObserverCallsException()); scope = default; return false; } - if (_callsCount == 0) - { - _entryThreadId = currentThreadId; - } + var newThreadId = oldCount == 0 ? currentThreadId : oldThreadId; + var newState = ((long)newThreadId << 32) | (uint)(oldCount + 1); - _callsCount++; - - // Avoid allocating a linked CTS when the caller token is None, our own dispose token, or an - // upstream token we already linked via LinkExternalCancellation (its cancellation already - // propagates to _disposeCts, so combining it again would allocate a redundant CTS chain). - if (cancellationToken == CancellationToken.None - || cancellationToken == _disposeCts.Token - || cancellationToken == _externalLinkedToken) - { - scope = new(null, _disposeCts.Token); - } - else + if (Interlocked.CompareExchange(ref _callState, newState, oldState) != oldState) { - var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); - scope = new(linkedCts, linkedCts.Token); + continue; } + scope = BuildLinkedTokenScope(cancellationToken); return true; } } @@ -308,23 +283,35 @@ internal bool TryEnterOnSomethingCall(CancellationToken cancellationToken, out L [DebuggerStepThrough] internal bool ExitOnSomethingCall() { - lock (_gate) + while (true) { - _callsCount--; - Debug.Assert(_callsCount >= 0, "Calls count should never be negative."); - if (_callsCount == 0) + var oldState = Volatile.Read(ref _callState); + var oldCount = (int)oldState; + var oldThreadId = (int)(oldState >> 32); + + Debug.Assert(oldCount > 0, "Calls count should be positive when exiting."); + + var newCount = oldCount - 1; + var newThreadId = newCount == 0 ? 0 : oldThreadId; + var newState = ((long)newThreadId << 32) | (uint)newCount; + + if (Interlocked.CompareExchange(ref _callState, newState, oldState) != oldState) { - _entryThreadId = 0; + continue; } - if (_allCallsCompletedTcs is not null) + if (newCount == 0) { - _allCallsCompletedTcs.SetResult(null); - return false; + var tcs = Volatile.Read(ref _allCallsCompletedTcs); + if (tcs is not null) + { + tcs.TrySetResult(null); + return false; + } } - } - return true; + return true; + } } /// @@ -374,21 +361,38 @@ internal async ValueTask OnErrorResumeAsync_Private(Exception error, Cancellatio [DebuggerStepThrough] protected void LinkExternalCancellation(CancellationToken external) { - if (!external.CanBeCanceled || external == _disposeCts.Token) + // No-op fast path: token can't fire, so the dispose chain doesn't need it. Skip CTS + // materialization entirely — terminal observers that get CancellationToken.None + // pay nothing. + if (!external.CanBeCanceled) { return; } + // External token already cancelled — materialize the CTS only to mark it cancelled + // (so future InternalDisposedToken consumers see the cancelled state). if (external.IsCancellationRequested) { - _disposeCts.Cancel(); + Volatile.Write(ref _disposed, 1); + GetOrCreateDisposeCts().Cancel(); + return; + } + + var cts = GetOrCreateDisposeCts(); + if (external == cts.Token) + { return; } _externalLinkRegistration.Dispose(); _externalLinkRegistration = external.UnsafeRegister( - static state => ((CancellationTokenSource)state!).Cancel(), - _disposeCts); + static state => + { + var self = (ObserverAsync)state!; + Volatile.Write(ref self._disposed, 1); + Volatile.Read(ref self._disposeCts)?.Cancel(); + }, + this); _externalLinkedToken = external; } @@ -401,30 +405,46 @@ protected void LinkExternalCancellation(CancellationToken external) [DebuggerStepThrough] protected virtual async ValueTask DisposeAsyncCore() { + // First-disposer wins the race. Lazy-CTS observers set the _disposed flag without + // necessarily materializing a CTS; only callers that previously requested the token + // need the cancellation broadcast. + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + Task? allOnSomethingCallsCompleted = null; - lock (_gate) + var initialState = Volatile.Read(ref _callState); + var initialCount = (int)initialState; + var initialThreadId = (int)(initialState >> 32); + + if (initialCount > 0 && initialThreadId != Environment.CurrentManagedThreadId) { - if (_disposeCts.IsCancellationRequested) - { - return; - } + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Volatile.Write(ref _allCallsCompletedTcs, tcs); - if (_callsCount > 0 && _entryThreadId != Environment.CurrentManagedThreadId) + // Re-read after publishing the TCS — Exit may have raced past us and decremented + // the count to zero before our publish became visible. Self-signal so the awaiter + // doesn't deadlock. + var stateAfter = Volatile.Read(ref _callState); + if ((int)stateAfter == 0) { - _allCallsCompletedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - allOnSomethingCallsCompleted = _allCallsCompletedTcs.Task; + tcs.TrySetResult(null); } + + allOnSomethingCallsCompleted = tcs.Task; } - // Two callers can both pass the IsCancellationRequested guard above (the guard is read - // in the lock but the actual cancellation happens outside, so the window between - // guard-passed and cancel-applied is non-zero). TryCancelAsync returns false for the - // race-loser — the winner already cancelled-and-disposed the CTS — and the loser then - // skips the rest of the teardown by not entering the branch. - if (await ConcurrencyRaceHelpers.TryCancelAsync(_disposeCts).ConfigureAwait(false)) + // Materialized CTS holders need cancellation propagated; lazy holders skip this step + // (any future InternalDisposedToken request will create a pre-cancelled CTS via + // GetOrCreateDisposeCts's post-disposed branch). + var cts = Volatile.Read(ref _disposeCts); + if (cts is not null) { - await CompleteDisposeAfterCancelAsync(allOnSomethingCallsCompleted).ConfigureAwait(false); + await ConcurrencyRaceHelpers.TryCancelAsync(cts).ConfigureAwait(false); } + + await CompleteDisposeAfterCancelAsync(allOnSomethingCallsCompleted).ConfigureAwait(false); } /// @@ -465,7 +485,7 @@ private async ValueTask CompleteDisposeAfterCancelAsync(Task? allOnSomethingCall #else _externalLinkRegistration.Dispose(); #endif - _disposeCts.Dispose(); + Volatile.Read(ref _disposeCts)?.Dispose(); try { @@ -583,6 +603,58 @@ private async ValueTask OnCompletedAsyncSlow(ValueTask core, LinkedTokenScope sc } } + /// Builds the for the current call, allocating a + /// linked CTS only when the caller token isn't already one of the fast-path equivalents + /// (, our own dispose token if materialized, or the + /// upstream token already linked via ). + /// The caller-supplied cancellation token. + /// A scope whose Token drives the in-flight call's cancellation. + private LinkedTokenScope BuildLinkedTokenScope(CancellationToken cancellationToken) + { + var existingDisposeToken = Volatile.Read(ref _disposeCts)?.Token ?? default; + if (cancellationToken == CancellationToken.None + || (existingDisposeToken.CanBeCanceled && cancellationToken == existingDisposeToken) + || cancellationToken == _externalLinkedToken) + { + return new LinkedTokenScope(null, existingDisposeToken); + } + + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, GetOrCreateDisposeCts().Token); + return new LinkedTokenScope(linkedCts, linkedCts.Token); + } + + /// Returns the existing or lazily creates it. If + /// was set before this call (the observer was disposed before any token was requested), the + /// freshly-created CTS is cancelled immediately so its + /// matches the post-dispose state callers expect. + /// The dispose CTS, freshly cancelled if disposal was already signaled. + private CancellationTokenSource GetOrCreateDisposeCts() => + Volatile.Read(ref _disposeCts) ?? MaterializeDisposeCts(); + + /// Creates and publishes the dispose CTS on first request, discarding the freshly-created instance + /// if another thread published one first, and pre-cancelling it when disposal was already signaled. + /// The published dispose CTS. + /// The compare-exchange-lost branch is only reachable when two threads materialize the CTS + /// concurrently; isolated here and excluded from coverage as race-only. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private CancellationTokenSource MaterializeDisposeCts() + { + var fresh = new CancellationTokenSource(); + var prior = Interlocked.CompareExchange(ref _disposeCts, fresh, null); + if (prior is not null) + { + fresh.Dispose(); + return prior; + } + + if (Volatile.Read(ref _disposed) != 0) + { + fresh.Cancel(); + } + + return fresh; + } + /// /// A lightweight scope that wraps an optional and exposes the /// effective . When no linked source is needed (e.g. the caller token diff --git a/src/ReactiveUI.Extensions/Async/Subjects/Base/BaseStatelessReplayLastSubjectAsync.cs b/src/ReactiveUI.Extensions/Async/Subjects/Base/BaseStatelessReplayLastSubjectAsync.cs index ab03f5d..03ab32d 100644 --- a/src/ReactiveUI.Extensions/Async/Subjects/Base/BaseStatelessReplayLastSubjectAsync.cs +++ b/src/ReactiveUI.Extensions/Async/Subjects/Base/BaseStatelessReplayLastSubjectAsync.cs @@ -73,18 +73,39 @@ public abstract class BaseStatelessReplayLastSubjectAsync(Optional startVa /// A task that represents the asynchronous notification operation. public async ValueTask OnNextAsync(T value, CancellationToken cancellationToken) { - using var linkedCts = - CancellationTokenSource.CreateLinkedTokenSource(DisposedCancellationToken, cancellationToken); - var token = linkedCts.Token; - - ImmutableArray> observers; - using (await _gate.LockAsync(token).ConfigureAwait(false)) + // Fast path: when the caller passes our own dispose token (or no token at all), the + // per-emission linked CTS is pure waste — DisposedCancellationToken already covers + // disposal-driven cancellation, so reuse it directly. + CancellationTokenSource? linkedCts = null; + CancellationToken token; + if (cancellationToken == DisposedCancellationToken || !cancellationToken.CanBeCanceled) { - _value = new(value); - observers = _observers; + token = DisposedCancellationToken; + } + else + { + linkedCts = CancellationTokenSource.CreateLinkedTokenSource(DisposedCancellationToken, cancellationToken); + token = linkedCts.Token; } - await OnNextAsyncCore(observers, value, token).ConfigureAwait(false); + try + { + ImmutableArray> observers; + using (await _gate.LockAsync(token).ConfigureAwait(false)) + { + _value = new(value); + observers = _observers; + } + + // Forward the caller's token (not the dispose-linked one) so downstream observers' + // fast-path equality check matches and they don't allocate a linked CTS per emission. + // The gate-protected snapshot above already isolates the broadcast from disposal. + await OnNextAsyncCore(observers, value, cancellationToken).ConfigureAwait(false); + } + finally + { + linkedCts?.Dispose(); + } } /// diff --git a/src/ReactiveUI.Extensions/Continuation.cs b/src/ReactiveUI.Extensions/Continuation.cs index fdb265b..7dd4f56 100644 --- a/src/ReactiveUI.Extensions/Continuation.cs +++ b/src/ReactiveUI.Extensions/Continuation.cs @@ -62,6 +62,27 @@ public Task Lock(T item, IObserver<(T value, IDisposable Sync)>? observer) return ScheduleSignalPhase(); } + /// + /// -returning counterpart to . Use this at per-emission + /// call sites where the returned task is awaited exactly once — saves the boxed + /// wrapper allocation in the already-locked fast path. + /// + /// The type of the elements in the source sequence. + /// The item. + /// The observer. + /// A representing the asynchronous operation. + public ValueTask LockValueTask(T item, IObserver<(T value, IDisposable Sync)>? observer) + { + if (_locked) + { + return default; + } + + _locked = true; + observer?.OnNext((item, this)); + return new ValueTask(ScheduleSignalPhase()); + } + /// /// UnLocks this instance. /// diff --git a/src/ReactiveUI.Extensions/Internal/ConcurrencyLimiter.cs b/src/ReactiveUI.Extensions/Internal/ConcurrencyLimiter.cs index c7b343d..7a6de89 100644 --- a/src/ReactiveUI.Extensions/Internal/ConcurrencyLimiter.cs +++ b/src/ReactiveUI.Extensions/Internal/ConcurrencyLimiter.cs @@ -1,139 +1,146 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; -using ReactiveUI.Extensions.Internal.Disposables; namespace ReactiveUI.Extensions.Internal; /// /// Limits the concurrency of task execution and emits results through an observable sequence. +/// Implements directly so the surface needs no +/// ActionDisposable closure wrappers; the per-task continuation state is the +/// per-subscription instance, which is already a reference type +/// and therefore needs no boxing through . /// /// The type of the task results. -internal class ConcurrencyLimiter +internal sealed class ConcurrencyLimiter : IObservable { - /// - /// The synchronization gate protecting task scheduling and completion state. - /// + /// The synchronization gate protecting task scheduling and completion state. #if NET9_0_OR_GREATER private readonly Lock _gate = new(); #else private readonly object _gate = new(); #endif - /// - /// Indicates whether this limiter has been disposed. Updated and read lock-free - /// via / ; the disposal path is a - /// single-field flip, so no monitor is needed. - /// - private int _disposed; + /// Source enumerable; the enumerator is pulled lazily on first . + private readonly IEnumerable> _taskFunctions; + + /// Maximum concurrent task continuations. + private readonly int _maxConcurrency; - /// - /// The number of tasks currently in flight that have not yet completed. - /// + /// The number of tasks currently in flight that have not yet completed. private int _outstanding; - /// - /// The enumerator over the source task sequence, or null if all tasks have been pulled or the limiter was disposed. - /// + /// Global disposal latch set by any . Preserves the + /// existing single-subscription-at-a-time semantics: once any consumer disposes, the limiter + /// stops pulling further tasks. + private int _disposed; + + /// Lazy enumerator over the source task sequence; once exhausted. private IEnumerator>? _rator; - /// - /// Initializes a new instance of the class. - /// - /// The task functions. + /// Initializes a new instance of the class. + /// The task functions to drain. /// The maximum concurrency. public ConcurrencyLimiter(IEnumerable> taskFunctions, int maxConcurrency) { - _rator = taskFunctions.GetEnumerator(); - Observable = new DelegateObservable(observer => - { - for (var i = 0; i < maxConcurrency; i++) - { - PullNextTask(observer); - } - - return new ActionDisposable(() => Disposed = true); - }); + _taskFunctions = taskFunctions; + _maxConcurrency = maxConcurrency; } - /// - /// Gets the i observable. - /// - public IObservable Observable { get; } + /// Gets the observable sequence — the limiter is its own . + public IObservable Observable => this; - /// - /// Gets or sets a value indicating whether this is disposed. - /// - /// true if disposed; otherwise, false. + /// Gets or sets a value indicating whether the limiter has been disposed by any + /// consumer. Exposed to internal tests; production paths set it via + /// . internal bool Disposed { get => Volatile.Read(ref _disposed) != 0; set => Interlocked.Exchange(ref _disposed, value ? 1 : 0); } - /// - /// Clears the rator. - /// + /// + public IDisposable Subscribe(IObserver observer) + { + var subscription = new Subscription(this, observer); + lock (_gate) + { + _rator ??= _taskFunctions.GetEnumerator(); + } + + for (var i = 0; i < _maxConcurrency; i++) + { + PullNextTask(subscription); + } + + return subscription; + } + + /// Clears the lazy enumerator. Caller must hold on the production + /// paths; exposed to internal tests that exercise the idempotent-second-call branch. internal void ClearRator() { _rator?.Dispose(); _rator = null; } - /// - /// Processes the task completion. - /// - /// The observer. - /// The decendant Task. + /// Test entry point that adapts a raw into a fresh + /// and pulls the next task. Production paths go through + /// which creates the subscription once. + /// The observer that will receive notifications. + internal void PullNextTask(IObserver observer) => + PullNextTask(new Subscription(this, observer)); + + /// Processes the completion of a previously-scheduled task. + /// The owning subscription. + /// The completed task. [SuppressMessage( "Major Bug", "S4462:Calls to async methods should not be blocking", Justification = "Task is guaranteed complete at this call site (IsFaulted/IsCanceled were both false above); reading .Result drives the synchronous IObserver contract without blocking.")] - internal void ProcessTaskCompletion(IObserver observer, Task decendantTask) + private void ProcessTaskCompletion(Subscription subscription, Task completed) { lock (_gate) { - if (Disposed || decendantTask.IsFaulted || decendantTask.IsCanceled) + if (subscription.Disposed || completed.IsFaulted || completed.IsCanceled) { ClearRator(); - if (!Disposed) + if (!subscription.Disposed) { - observer.OnError((decendantTask.Exception == null + subscription.Observer.OnError((completed.Exception == null ? new OperationCanceledException() - : decendantTask.Exception.InnerException)!); + : completed.Exception.InnerException)!); } + + return; + } + + subscription.Observer.OnNext(completed.Result); + if (--_outstanding == 0 && _rator == null) + { + subscription.Observer.OnCompleted(); } else { - observer.OnNext(decendantTask.Result); - if (--_outstanding == 0 && _rator == null) - { - observer.OnCompleted(); - } - else - { - PullNextTask(observer); - } + PullNextTask(subscription); } } } - /// - /// Pulls the next task. - /// - /// The observer. - internal void PullNextTask(IObserver observer) + /// Pulls the next task and schedules its continuation against this limiter. + /// The owning subscription. + private void PullNextTask(Subscription subscription) { lock (_gate) { - if (Disposed) + if (subscription.Disposed) { ClearRator(); } - if (_rator == null) + if (_rator is null) { return; } @@ -143,7 +150,7 @@ internal void PullNextTask(IObserver observer) ClearRator(); if (_outstanding == 0) { - observer.OnCompleted(); + subscription.Observer.OnCompleted(); } return; @@ -151,19 +158,39 @@ internal void PullNextTask(IObserver observer) _outstanding++; - // State-carrying ContinueWith so the continuation lambda doesn't capture `this` + `observer` - // in a closure. The state tuple is value-typed; boxing happens once at scheduling time inside - // ContinueWith itself, but no per-call closure object is allocated for the lambda. + // The continuation passes the Subscription as state — already a reference type, so + // no per-task ValueTuple boxing is needed. The static lambda preserves zero closure + // capture. _rator.Current?.ContinueWith( static (ant, state) => { - var (limiter, observer) = ((ConcurrencyLimiter Limiter, IObserver Observer))state!; - limiter.ProcessTaskCompletion(observer, ant); + var sub = (Subscription)state!; + sub.Limiter.ProcessTaskCompletion(sub, ant); }, - (this, observer), + subscription, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } + + /// Per-subscription handle: holds the observer reference and a disposal latch. + /// Replaces the previous ActionDisposable(() => Disposed = true) pattern with a + /// dedicated class — no closure object per subscribe. + /// The owning limiter. + /// The downstream observer. + internal sealed class Subscription(ConcurrencyLimiter limiter, IObserver observer) : IDisposable + { + /// Gets the owning limiter. + public ConcurrencyLimiter Limiter { get; } = limiter; + + /// Gets the downstream observer. + public IObserver Observer { get; } = observer; + + /// Gets a value indicating whether the subscription has been disposed. + public bool Disposed => Limiter.Disposed; + + /// + public void Dispose() => Limiter.Disposed = true; + } } diff --git a/src/ReactiveUI.Extensions/Internal/CurrentValueSubject.cs b/src/ReactiveUI.Extensions/Internal/CurrentValueSubject.cs index a8b3dc4..678d9d5 100644 --- a/src/ReactiveUI.Extensions/Internal/CurrentValueSubject.cs +++ b/src/ReactiveUI.Extensions/Internal/CurrentValueSubject.cs @@ -290,23 +290,27 @@ private void Unsubscribe(IObserver observer) } } - /// Per-subscription handle that detaches the observer on dispose. + /// Per-subscription handle that detaches the observer on dispose. Idempotency is + /// enforced via on — + /// the second dispose sees and returns. Eliminates the previous + /// dedicated _disposed int and shaves a field off every subscription. /// The owning subject. /// The observer to detach. private sealed class Subscription(CurrentValueSubject parent, IObserver observer) : IDisposable { - /// Latches to 1 on the first dispose so detach is idempotent. - private int _disposed; + /// Observer reference captured at attach time; nulled atomically on first dispose. + private IObserver? _observer = observer; /// public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + var captured = Interlocked.Exchange(ref _observer, null); + if (captured is null) { return; } - parent.Unsubscribe(observer); + parent.Unsubscribe(captured); } } diff --git a/src/ReactiveUI.Extensions/Internal/DelegateObservable.cs b/src/ReactiveUI.Extensions/Internal/DelegateObservable.cs deleted file mode 100644 index 203a008..0000000 --- a/src/ReactiveUI.Extensions/Internal/DelegateObservable.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. -// ReactiveUI Association Incorporated licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. - -namespace ReactiveUI.Extensions.Internal; - -/// -/// Builds an from a subscribe-time factory delegate. Cheaper than -/// the Rx Observable.Create path because there is no wrapping safe-observer or auto-detach -/// machinery — the factory is responsible for completing or erroring the observer itself. -/// -/// The element type. -/// Invoked per subscription; receives the observer and returns the disposal handle. -internal sealed class DelegateObservable(Func, IDisposable> subscribe) : IObservable -{ - /// - public IDisposable Subscribe(IObserver observer) - { - ArgumentExceptionHelper.ThrowIfNull(observer); - return subscribe(observer); - } -} diff --git a/src/ReactiveUI.Extensions/Internal/DrainNotificationKind.cs b/src/ReactiveUI.Extensions/Internal/DrainNotificationKind.cs new file mode 100644 index 0000000..b4f3c03 --- /dev/null +++ b/src/ReactiveUI.Extensions/Internal/DrainNotificationKind.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Extensions.Internal; + +/// Kind of upstream notification enqueued for a drain pass. +internal enum DrainNotificationKind +{ + /// OnNext with a value. + Next, + + /// OnError with an exception. + Error, + + /// OnCompleted (no value). + Completed, +} diff --git a/src/ReactiveUI.Extensions/Internal/FirstAsValueTaskHelper.cs b/src/ReactiveUI.Extensions/Internal/FirstAsValueTaskHelper.cs new file mode 100644 index 0000000..b516bc9 --- /dev/null +++ b/src/ReactiveUI.Extensions/Internal/FirstAsValueTaskHelper.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Threading.Tasks.Sources; + +namespace ReactiveUI.Extensions.Internal; + +/// +/// -returning counterpart to . Backs the +/// ToHotValueTask extension with a pooled implementation +/// so steady-state callers pay zero allocations after the pool warms up. +/// +/// The element type. +internal static class FirstAsValueTaskHelper +{ + /// Single-slot pool. null when the previous instance is in flight. + private static PooledFirstObserver? _pooled; + + /// Subscribes once and resolves a with the first value. + /// The source observable. + /// A value task that completes with the first value, faults on error, or faults on empty completion. + public static ValueTask FirstAsValueTask(IObservable source) + { + ArgumentExceptionHelper.ThrowIfNull(source); + + var inst = Interlocked.Exchange(ref _pooled, null) ?? new PooledFirstObserver(); + return inst.Begin(source); + } + + /// Pooled combined + . + private sealed class PooledFirstObserver : IValueTaskSource, IObserver + { + /// The reset-able backing store for the machinery. + private ManualResetValueTaskSourceCore _core = new() { RunContinuationsAsynchronously = true }; + + /// Latches to 1 once the source has been settled so subsequent callbacks no-op. + private int _settled; + + /// The upstream subscription, retained so can cancel it on first match. + private IDisposable? _subscription; + + /// Begins a fresh capture cycle: resets the core, subscribes the source, returns the task. + /// The source observable to subscribe to. + /// The value task observers consume to receive the first value. + public ValueTask Begin(IObservable source) + { + _core.Reset(); + _settled = 0; + _subscription = source.Subscribe(this); + return new ValueTask(this, _core.Version); + } + + /// + public void OnNext(T value) + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + _subscription?.Dispose(); + _core.SetResult(value); + } + + /// + public void OnError(Exception error) + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + _core.SetException(error); + } + + /// + public void OnCompleted() + { + if (Interlocked.Exchange(ref _settled, 1) != 0) + { + return; + } + + _core.SetException(new InvalidOperationException("Sequence contains no elements.")); + } + + /// + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => _core.GetStatus(token); + + /// + void IValueTaskSource.OnCompleted( + Action continuation, + object? state, + short token, + ValueTaskSourceOnCompletedFlags flags) => + _core.OnCompleted(continuation, state, token, flags); + + /// + T IValueTaskSource.GetResult(short token) + { + try + { + return _core.GetResult(token); + } + finally + { + _subscription = null; + Interlocked.CompareExchange(ref _pooled, this, null); + } + } + } +} diff --git a/src/ReactiveUI.Extensions/Internal/IDrainTarget.cs b/src/ReactiveUI.Extensions/Internal/IDrainTarget.cs new file mode 100644 index 0000000..b3d7c45 --- /dev/null +++ b/src/ReactiveUI.Extensions/Internal/IDrainTarget.cs @@ -0,0 +1,16 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Extensions.Internal; + +/// +/// Implemented by sinks that drive a . The state helper invokes +/// once per scheduled burst via a static scheduler callback, so passing the sink +/// as an keeps the scheduled action allocation-free (no captured closure). +/// +internal interface IDrainTarget +{ + /// Drains the queued notifications on the scheduler thread. + void Drain(); +} diff --git a/src/ReactiveUI.Extensions/Internal/ScheduledDrainState.cs b/src/ReactiveUI.Extensions/Internal/ScheduledDrainState.cs new file mode 100644 index 0000000..eb9eff6 --- /dev/null +++ b/src/ReactiveUI.Extensions/Internal/ScheduledDrainState.cs @@ -0,0 +1,168 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using ReactiveUI.Extensions.Internal.Disposables; + +namespace ReactiveUI.Extensions.Internal; + +/// +/// Shared queue-and-single-drain marshaller used by the synchronous scheduler-marshalling operator +/// sinks (ObserveOn, Conflate). Each of those sinks previously hand-rolled the same gate, +/// FIFO queue, drain-in-flight flag, terminal flag, enqueue-and-schedule logic, and dequeue loop on top +/// of identical fields; this helper centralises that machinery so the per-sink class only carries the +/// operator-specific notification handling. Notifications are enqueued and a single drain pass is +/// scheduled per burst (rather than one scheduled action per item), and the drain callback carries no +/// captures — the sink is passed through as an . Sinks compose one instance +/// and forward to it; there is no base class and no per-item virtual dispatch. +/// +/// The element type carried by notifications. +/// The scheduler each drain pass runs on. +/// The sink whose the scheduled pass invokes. +internal sealed class ScheduledDrainState(IScheduler scheduler, IDrainTarget target) +{ + /// The FIFO queue of pending upstream notifications. + private readonly Queue _queue = new(); + + /// Upstream subscription handle, recorded via for teardown on dispose. + private IDisposable? _sourceSubscription; + + /// while a drain pass is in flight on the scheduler. + private bool _draining; + + /// once a terminal notification has been delivered or the sink disposed. + private bool _done; + + /// Gets the gate protecting the queue, drain flag, done flag, and the composing sink's own + /// operator-specific state. Sinks lock this directly when guarding their extra fields. +#if NET9_0_OR_GREATER + public Lock Gate { get; } = new(); +#else + public object Gate { get; } = new(); +#endif + + /// Gets a value indicating whether the sink has reached a terminal state. Read inside + /// by callers that need to short-circuit once terminated. + public bool Done => _done; + + /// Enqueues an OnNext notification and schedules a drain pass if one isn't already running. + /// The value to forward downstream. + public void EnqueueNext(T value) => Enqueue(new Notification(DrainNotificationKind.Next, value, null)); + + /// Enqueues an OnError notification and schedules a drain pass if one isn't already running. + /// The error to forward downstream. + public void EnqueueError(Exception error) => Enqueue(new Notification(DrainNotificationKind.Error, default!, error)); + + /// Enqueues an OnCompleted notification and schedules a drain pass if one isn't already running. + public void EnqueueCompleted() => Enqueue(new Notification(DrainNotificationKind.Completed, default!, null)); + + /// Records the upstream subscription, or disposes it immediately if the sink is already done. + /// The upstream subscription handle. + public void Attach(IDisposable subscription) + { + lock (Gate) + { + if (!_done) + { + _sourceSubscription = subscription; + return; + } + } + + subscription.Dispose(); + } + + /// Dequeues the next pending notification, clearing the drain flag when the queue empties or + /// the sink has terminated. + /// The dequeued notification when this returns . + /// if a notification was dequeued; otherwise . + public bool TryDequeue(out Notification notification) + { + lock (Gate) + { + if (_done || _queue.Count == 0) + { + _draining = false; + notification = default; + return false; + } + + notification = _queue.Dequeue(); + return true; + } + } + + /// Marks the sink terminated and drops any still-queued notifications. Locks . + public void Terminate() + { + lock (Gate) + { + _done = true; + _queue.Clear(); + } + } + + /// Marks the sink terminated without clearing the queue. Caller must hold ; + /// the still-queued notifications are abandoned because checks the done flag first. + public void MarkDoneLocked() => _done = true; + + /// Begins disposal under : marks the sink done, clears the queue, and detaches + /// the upstream subscription — returned to the caller so it is disposed outside the gate. Returns + /// when already disposed. + /// The upstream subscription to dispose outside the gate, or . + public IDisposable? BeginDispose() + { + lock (Gate) + { + return _done ? null : BeginDisposeLocked(); + } + } + + /// Marks the sink done, clears the queue, and detaches the upstream subscription, returning it for + /// disposal outside the gate. Caller must hold and have confirmed is + /// . Lets a composing sink dispose its own scheduled-work slot atomically with the + /// done transition under the same lock. + /// The upstream subscription to dispose outside the gate, or . + public IDisposable? BeginDisposeLocked() + { + _done = true; + _queue.Clear(); + var subscription = _sourceSubscription; + _sourceSubscription = null; + return subscription; + } + + /// Enqueues a notification; claims and schedules a single drain pass if one isn't running. + /// The notification to forward to the drain loop. + private void Enqueue(in Notification notification) + { + lock (Gate) + { + if (_done) + { + return; + } + + _queue.Enqueue(notification); + if (_draining) + { + return; + } + + _draining = true; + } + + scheduler.Schedule(target, static (_, drainTarget) => + { + drainTarget.Drain(); + return EmptyDisposable.Instance; + }); + } + + /// Discriminated notification payload enqueued for the scheduled drain. + /// The notification kind. + /// The element carried by ; default otherwise. + /// The error carried by ; null otherwise. + internal readonly record struct Notification(DrainNotificationKind Kind, T Value, Exception? Error); +} diff --git a/src/ReactiveUI.Extensions/Observables.cs b/src/ReactiveUI.Extensions/Observables.cs new file mode 100644 index 0000000..87db2dc --- /dev/null +++ b/src/ReactiveUI.Extensions/Observables.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Extensions.Internal; + +namespace ReactiveUI.Extensions; + +/// +/// Factory methods that build instances. Sync-side counterpart to +/// . The plural name avoids the resolution collision with +/// at call sites that import both namespaces. +/// +public static class Observables +{ + /// + /// Returns an observable sequence that emits a single value and completes synchronously inside + /// . + /// + /// The element type. + /// The value emitted to every subscriber. + /// An observable that emits and completes on subscribe. + public static IObservable Return(T value) => new SingleValueObservable(value); +} diff --git a/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs b/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs index 78e29f2..67cc682 100644 --- a/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs @@ -20,19 +20,6 @@ internal sealed class ConflateObservable( TimeSpan minimumUpdatePeriod, IScheduler scheduler) : IObservable { - /// Notification kind enqueued by the scheduler marshaller. - private enum NotificationKind - { - /// OnNext with a value. - Next, - - /// OnError with an exception. - Error, - - /// OnCompleted (no value). - Completed, - } - /// public IDisposable Subscribe(IObserver observer) { @@ -41,208 +28,133 @@ public IDisposable Subscribe(IObserver observer) ArgumentExceptionHelper.ThrowIfNull(observer); var sink = new ConflateSink(observer, minimumUpdatePeriod, scheduler); - var marshaller = new SchedulerMarshaller(sink, scheduler); - var subscription = source.Subscribe(marshaller); - return new DisposableBag(subscription, marshaller, sink); + sink.AttachSourceSubscription(source.Subscribe(sink)); + return sink; } /// - /// Discriminated payload enqueued by . Replaces the per-emission - /// closure / delegate trio that the previous source.ObserveOn(scheduler).Subscribe(sink) - /// allocated. + /// Single observer that combines two previously-distinct concerns into one allocation: + /// (1) marshals upstream notifications onto the scheduler thread — delegated to the shared + /// FIFO queue and scheduled drain — and (2) applies the conflate + /// time-window throttle to each notification. End-user-observable + /// semantics are unchanged from the prior two-observer implementation. /// - /// The notification kind. - /// The element carried by ; default otherwise. - /// The error carried by ; null otherwise. - private readonly record struct Notification(NotificationKind Kind, T Value, Exception? Error); - - /// - /// FIFO scheduler marshaller that replaces source.ObserveOn(scheduler). Each upstream - /// notification is enqueued and a single drain action is scheduled on the scheduler; the drain - /// runs every queued notification in order before yielding. New notifications arriving during a - /// drain are picked up by the same drain pass. - /// - /// The downstream observer to forward notifications to. - /// The scheduler used to dispatch the drain. - internal sealed class SchedulerMarshaller(IObserver downstream, IScheduler scheduler) - : IObserver, IDisposable + internal sealed class ConflateSink : IObserver, IDisposable, IDrainTarget { -#if NET9_0_OR_GREATER - /// Protects , , and . - private readonly Lock _gate = new(); -#else - /// Protects , , and . - private readonly object _gate = new(); -#endif + /// The downstream observer. + private readonly IObserver _downstream; + + /// The minimum period between emissions. + private readonly TimeSpan _minimumUpdatePeriod; + + /// The scheduler to run the conflation on. + private readonly IScheduler _scheduler; - /// The FIFO queue of pending notifications. - private readonly Queue _queue = new(); + /// Shared queue / gate / scheduled-drain machinery. + private readonly ScheduledDrainState _state; - /// true while a drain pass is in flight. - private bool _draining; + /// The disposable tracking a scheduled deferred emission. + private readonly MutableDisposable _updateScheduled = new(); + + /// Wall-clock timestamp of the last emission forwarded downstream. + private DateTimeOffset _lastUpdateTime = DateTimeOffset.MinValue; + + /// when an upstream OnCompleted is queued but a deferred + /// emission is still pending; the completion fires after that emission lands. + private bool _completionRequested; - /// true after disposal. - private bool _disposed; + /// Initializes a new instance of the class. + /// The downstream observer. + /// The minimum period between emissions. + /// The scheduler to run the conflation on. + public ConflateSink(IObserver downstream, TimeSpan minimumUpdatePeriod, IScheduler scheduler) + { + _downstream = downstream; + _minimumUpdatePeriod = minimumUpdatePeriod; + _scheduler = scheduler; + _state = new ScheduledDrainState(scheduler, this); + } + + /// Records the upstream subscription so can tear it down. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) => _state.Attach(subscription); /// - public void OnNext(T value) => Enqueue(new Notification(NotificationKind.Next, value, null)); + public void OnNext(T value) => _state.EnqueueNext(value); /// - public void OnError(Exception error) => Enqueue(new Notification(NotificationKind.Error, default!, error)); + public void OnError(Exception error) => _state.EnqueueError(error); /// - public void OnCompleted() => Enqueue(new Notification(NotificationKind.Completed, default!, null)); + public void OnCompleted() => _state.EnqueueCompleted(); /// public void Dispose() { - lock (_gate) - { - _disposed = true; - _queue.Clear(); - } - } - - /// - /// Enqueues a notification and, if no drain is already in flight, schedules one onto the - /// configured scheduler. - /// - /// The notification to forward. - private void Enqueue(Notification notification) - { - bool scheduleDrain; - lock (_gate) + IDisposable? subscription; + lock (_state.Gate) { - if (_disposed) + if (_state.Done) { return; } - _queue.Enqueue(notification); - scheduleDrain = !_draining; - if (scheduleDrain) - { - _draining = true; - } - } - - if (!scheduleDrain) - { - return; + subscription = _state.BeginDisposeLocked(); + _updateScheduled.Dispose(); } - scheduler.Schedule(this, static (_, self) => - { - self.Drain(); - return EmptyDisposable.Instance; - }); + subscription?.Dispose(); } - /// - /// Drains every queued notification synchronously inside one scheduler callback. Terminal - /// notifications (OnError / OnCompleted) end the drain and leave - /// true so no further drains are scheduled. - /// - private void Drain() + /// + void IDrainTarget.Drain() { - while (true) + while (_state.TryDequeue(out var notification)) { - Notification notification; - lock (_gate) - { - if (_disposed || _queue.Count == 0) - { - _draining = false; - return; - } - - notification = _queue.Dequeue(); - } - switch (notification.Kind) { - case NotificationKind.Next: + case DrainNotificationKind.Next: { - downstream.OnNext(notification.Value); + ProcessNext(notification.Value); break; } - case NotificationKind.Error: + case DrainNotificationKind.Error: { - downstream.OnError(notification.Error!); + ForwardError(notification.Error!); return; } default: { - // NotificationKind has only three values; the discard arm absorbs - // Completed so the compiler sees an exhaustive switch and coverage - // stops counting a phantom default fall-through. - downstream.OnCompleted(); + // DrainNotificationKind has only three values; the discard arm absorbs + // Completed so the compiler sees an exhaustive switch. + ForwardCompleted(); return; } } } } - } - - /// - /// Sink for the conflate operator. - /// - /// The downstream observer. - /// The minimum period between emissions. - /// The scheduler to run the conflation on. - internal sealed class ConflateSink( - IObserver downstream, - TimeSpan minimumUpdatePeriod, - IScheduler scheduler) : IObserver, IDisposable - { -#if NET9_0_OR_GREATER - /// - /// The gate to synchronize access to the state. - /// - private readonly Lock _gate = new(); -#else - /// - /// The gate to synchronize access to the state. - /// - private readonly object _gate = new(); -#endif - - /// - /// The disposable for the scheduled update. - /// - private readonly MutableDisposable _updateScheduled = new(); - - /// - /// The last time an update was sent. - /// - private DateTimeOffset _lastUpdateTime = DateTimeOffset.MinValue; - - /// - /// Whether a completion has been requested. - /// - private bool _completionRequested; - /// - /// Whether the sink is done. - /// - private bool _done; - - /// - public void OnNext(T value) + /// Applies the throttle-window decision to a dequeued value and either emits inline or + /// schedules a deferred emission. The emission bodies live in covered helpers; only this + /// race-guarded shell (whose already-done early-out is reachable only when a concurrent dispose + /// flips the flag between the drain dequeue and this gate acquisition) is excluded. + /// The value to forward. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void ProcessNext(T value) { - var currentUpdateTime = scheduler.Now; + var currentUpdateTime = _scheduler.Now; bool scheduleRequired; - lock (_gate) + lock (_state.Gate) { - if (_done) + if (_state.Done) { return; } - scheduleRequired = currentUpdateTime - _lastUpdateTime < minimumUpdatePeriod; + scheduleRequired = currentUpdateTime - _lastUpdateTime < _minimumUpdatePeriod; if (scheduleRequired && _updateScheduled.Disposable != null) { _updateScheduled.Disposable.Dispose(); @@ -252,56 +164,77 @@ public void OnNext(T value) if (scheduleRequired) { - _updateScheduled.Disposable = scheduler.Schedule( - _lastUpdateTime + minimumUpdatePeriod, - () => - { - downstream.OnNext(value); - - lock (_gate) - { - _lastUpdateTime = scheduler.Now; - _updateScheduled.Disposable = null; - if (_completionRequested) - { - _done = true; - downstream.OnCompleted(); - } - } - }); + ScheduleDeferredEmission(value); } else { - downstream.OnNext(value); - lock (_gate) + EmitInline(value); + } + } + + /// Schedules a deferred emission of at the end of the throttle window, + /// forwarding a pending completion once it lands. + /// The value to emit when the window elapses. + private void ScheduleDeferredEmission(T value) => + _updateScheduled.Disposable = _scheduler.Schedule( + _lastUpdateTime + _minimumUpdatePeriod, + () => { - _lastUpdateTime = scheduler.Now; - } + _downstream.OnNext(value); + + lock (_state.Gate) + { + _lastUpdateTime = _scheduler.Now; + _updateScheduled.Disposable = null; + if (_completionRequested) + { + _state.MarkDoneLocked(); + _downstream.OnCompleted(); + } + } + }); + + /// Emits immediately and records the emission time. + /// The value to emit. + private void EmitInline(T value) + { + _downstream.OnNext(value); + lock (_state.Gate) + { + _lastUpdateTime = _scheduler.Now; } } - /// - public void OnError(Exception error) + /// Forwards an error to downstream and terminates the sink. + /// The error to forward. + /// The already-terminated early-out is reachable only when a concurrent dispose flips the + /// flag between the drain dequeue and this gate acquisition; excluded as race-only. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void ForwardError(Exception error) { - lock (_gate) + lock (_state.Gate) { - if (_done) + if (_state.Done) { return; } - _done = true; + _state.MarkDoneLocked(); _updateScheduled.Dispose(); - downstream.OnError(error); } + + _downstream.OnError(error); } - /// - public void OnCompleted() + /// Forwards completion, deferring if a throttled emission is still scheduled. + /// The already-terminated early-out is reachable only when a concurrent dispose flips the + /// flag between the drain dequeue and this gate acquisition; excluded as race-only. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void ForwardCompleted() { - lock (_gate) + lock (_state.Gate) { - if (_done) + if (_state.Done) { return; } @@ -309,23 +242,13 @@ public void OnCompleted() if (_updateScheduled.Disposable != null) { _completionRequested = true; + return; } - else - { - _done = true; - downstream.OnCompleted(); - } - } - } - /// - public void Dispose() - { - lock (_gate) - { - _done = true; - _updateScheduled.Dispose(); + _state.MarkDoneLocked(); } + + _downstream.OnCompleted(); } } } diff --git a/src/ReactiveUI.Extensions/Operators/DetectStaleObservable.cs b/src/ReactiveUI.Extensions/Operators/DetectStaleObservable.cs index 8a0e6c2..5322245 100644 --- a/src/ReactiveUI.Extensions/Operators/DetectStaleObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/DetectStaleObservable.cs @@ -28,9 +28,9 @@ public IDisposable Subscribe(IObserver> observer) ArgumentExceptionHelper.ThrowIfNull(observer); var sink = new DetectStaleSink(observer, stalenessPeriod, scheduler); - var sub = source.Subscribe(sink); + sink.AttachSourceSubscription(source.Subscribe(sink)); sink.Initialize(); - return new DisposableBag(sub, sink); + return sink; } /// @@ -48,6 +48,26 @@ private sealed class DetectStaleSink( /// Shared gate / timer / done-flag plumbing. private readonly TimerSinkState> _state = new(downstream); + /// Upstream subscription handle, set once via + /// so the sink can tear it down on dispose without a wrapper bag. + private IDisposable? _sourceSubscription; + + /// Records the upstream subscription for disposal. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) + { + lock (_state.Gate) + { + if (_state.Done) + { + subscription.Dispose(); + return; + } + + _sourceSubscription = subscription; + } + } + /// Initializes the staleness timer. public void Initialize() => ScheduleStale(); @@ -73,19 +93,31 @@ public void OnNext(T value) public void OnCompleted() => _state.HandleCompleted(); /// - public void Dispose() => _state.HandleDispose(); + public void Dispose() + { + _state.HandleDispose(); + Interlocked.Exchange(ref _sourceSubscription, null)?.Dispose(); + } - /// Schedules the staleness notification. + /// Schedules the staleness notification. Uses the state-carrying scheduler + /// overload with a static lambda so no per-reschedule closure capturing this is + /// allocated (the timer re-arms on every upstream emission). private void ScheduleStale() => - _state.Timer.Disposable = scheduler.Schedule(stalenessPeriod, () => + _state.Timer.Disposable = scheduler.Schedule(this, stalenessPeriod, static (_, self) => self.OnStaleTimer()); + + /// Fires the stale marker downstream when the staleness window elapses. + /// The singleton empty disposable for the scheduler contract. + private EmptyDisposable OnStaleTimer() + { + lock (_state.Gate) { - lock (_state.Gate) + if (!_state.Done) { - if (!_state.Done) - { - downstream.OnNext(new Stale()); - } + downstream.OnNext(new Stale()); } - }); + } + + return EmptyDisposable.Instance; + } } } diff --git a/src/ReactiveUI.Extensions/Operators/DoOnDisposeObservable.cs b/src/ReactiveUI.Extensions/Operators/DoOnDisposeObservable.cs index 1d65f78..4d209a1 100644 --- a/src/ReactiveUI.Extensions/Operators/DoOnDisposeObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/DoOnDisposeObservable.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using ReactiveUI.Extensions.Internal; -using ReactiveUI.Extensions.Internal.Disposables; namespace ReactiveUI.Extensions.Operators; @@ -24,9 +23,29 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(disposeAction); ArgumentExceptionHelper.ThrowIfNull(observer); - var subscription = source.Subscribe(observer); - return new ActionDisposable(() => + return new DoOnDisposeSubscription(source.Subscribe(observer), disposeAction); + } + + /// + /// Per-subscribe disposal handle that forwards to the source + /// subscription and then to the caller-supplied action. Dedicated class instead of the + /// previous ActionDisposable(() => …) form so no closure is allocated per subscribe. + /// + /// The upstream subscription disposed before the action fires. + /// The action executed once after the upstream is disposed. + private sealed class DoOnDisposeSubscription(IDisposable subscription, Action disposeAction) : IDisposable + { + /// Latches to 1 on the first dispose so the action fires exactly once. + private int _disposed; + + /// + public void Dispose() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + try { subscription.Dispose(); @@ -35,6 +54,6 @@ public IDisposable Subscribe(IObserver observer) { disposeAction(); } - }); + } } } diff --git a/src/ReactiveUI.Extensions/Operators/DropIfBusyObservable.cs b/src/ReactiveUI.Extensions/Operators/DropIfBusyObservable.cs index fe5d29c..18f1bf0 100644 --- a/src/ReactiveUI.Extensions/Operators/DropIfBusyObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/DropIfBusyObservable.cs @@ -16,7 +16,7 @@ namespace ReactiveUI.Extensions.Operators; /// The asynchronous action to execute for each forwarded element. internal sealed class DropIfBusyObservable( IObservable source, - Func asyncAction) : IObservable + Func asyncAction) : IObservable { /// public IDisposable Subscribe(IObserver observer) @@ -36,7 +36,7 @@ public IDisposable Subscribe(IObserver observer) /// The async action to run. private sealed class DropIfBusySink( IObserver downstream, - Func asyncAction) : IObserver, IDisposable + Func asyncAction) : IObserver, IDisposable { /// 0 = idle, 1 = busy. private int _isBusy; diff --git a/src/ReactiveUI.Extensions/Operators/FirstMatchFromCandidatesObservable.cs b/src/ReactiveUI.Extensions/Operators/FirstMatchFromCandidatesObservable.cs index 9779fe1..7399382 100644 --- a/src/ReactiveUI.Extensions/Operators/FirstMatchFromCandidatesObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/FirstMatchFromCandidatesObservable.cs @@ -63,7 +63,11 @@ public IDisposable Subscribe(IObserver observer) /// The subscription disposable. internal IDisposable TrySyncLoop(IObserver observer) { - var probe = new SyncProbe(); + // Reuse the SyncProbe across subscribes on the current thread — it carries no + // per-call state once Reset, so per-cycle allocation drops to zero on the fast path. + // Race-free because the field is [ThreadStatic]; only one TrySyncLoop call can be + // active per thread. + var probe = SyncProbe.RentForCurrentThread(); for (var i = 0; i < candidates.Count; i++) { @@ -80,6 +84,7 @@ internal IDisposable TrySyncLoop(IObserver observer) sub.Dispose(); var sink = new AsyncSink(observer, candidates, project, transform, predicate, fallback, i); sink.TryNext(); + SyncProbe.ReturnToCurrentThread(probe); return sink; } @@ -101,12 +106,14 @@ internal IDisposable TrySyncLoop(IObserver observer) { observer.OnNext(transformed); observer.OnCompleted(); + SyncProbe.ReturnToCurrentThread(probe); return EmptyDisposable.Instance; } } observer.OnNext(fallback); observer.OnCompleted(); + SyncProbe.ReturnToCurrentThread(probe); return EmptyDisposable.Instance; } @@ -117,6 +124,11 @@ internal IDisposable TrySyncLoop(IObserver observer) /// internal sealed class SyncProbe : IObserver { + /// Per-thread cached instance; rented on entry to TrySyncLoop and returned + /// on exit. Eliminates the per-subscribe allocation on the fast path. + [ThreadStatic] + private static SyncProbe? _cached; + /// Gets a value indicating whether OnNext was called. internal bool HasValue { get; private set; } @@ -129,6 +141,25 @@ internal sealed class SyncProbe : IObserver /// Gets the value received via OnNext. internal TRaw? Value { get; private set; } + /// Rents a probe from the current-thread cache, allocating only if the slot is empty. + /// A fresh-reset probe ready for use. + public static SyncProbe RentForCurrentThread() + { + var rented = _cached; + if (rented is null) + { + return new SyncProbe(); + } + + _cached = null; + rented.Reset(); + return rented; + } + + /// Returns a probe to the current-thread cache for reuse on the next call. + /// The probe instance to cache. + public static void ReturnToCurrentThread(SyncProbe probe) => _cached = probe; + /// public void OnNext(TRaw value) { diff --git a/src/ReactiveUI.Extensions/Operators/ForEachObservable.cs b/src/ReactiveUI.Extensions/Operators/ForEachObservable.cs index 1224cc7..b72e80c 100644 --- a/src/ReactiveUI.Extensions/Operators/ForEachObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/ForEachObservable.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Concurrency; -using System.Reactive.Linq; using ReactiveUI.Extensions.Internal; namespace ReactiveUI.Extensions.Operators; @@ -28,7 +27,7 @@ public IDisposable Subscribe(IObserver observer) InvalidOperationExceptionHelper.ThrowIfNull(source); ArgumentExceptionHelper.ThrowIfNull(observer); - var observed = scheduler is null ? source : source.ObserveOn(scheduler); + var observed = scheduler is null ? source : new ObserveOnObservable>(source, scheduler); return observed.Subscribe(new ForEachObserver(observer)); } diff --git a/src/ReactiveUI.Extensions/Operators/HeartbeatObservable.cs b/src/ReactiveUI.Extensions/Operators/HeartbeatObservable.cs index cae6c19..f1a0c17 100644 --- a/src/ReactiveUI.Extensions/Operators/HeartbeatObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/HeartbeatObservable.cs @@ -28,9 +28,9 @@ public IDisposable Subscribe(IObserver> observer) ArgumentExceptionHelper.ThrowIfNull(observer); var sink = new HeartbeatSink(observer, heartbeatPeriod, scheduler); - var subscription = source.Subscribe(sink); + sink.AttachSourceSubscription(source.Subscribe(sink)); sink.Initialize(); - return new DisposableBag(subscription, sink); + return sink; } /// @@ -61,11 +61,31 @@ private sealed class HeartbeatSink( /// private readonly MutableDisposable _timerSubscription = new(); + /// Upstream subscription handle; set once via + /// so the sink can tear it down in without needing a wrapper bag. + private IDisposable? _sourceSubscription; + /// /// Whether the sink has completed or been disposed. /// private bool _done; + /// Records the upstream subscription so can tear it down. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) + { + lock (_gate) + { + if (_done) + { + subscription.Dispose(); + return; + } + + _sourceSubscription = subscription; + } + } + /// /// Initializes the heartbeat timer. /// @@ -121,11 +141,16 @@ public void OnCompleted() /// public void Dispose() { + IDisposable? subscription; lock (_gate) { _done = true; _timerSubscription.Dispose(); + subscription = _sourceSubscription; + _sourceSubscription = null; } + + subscription?.Dispose(); } /// diff --git a/src/ReactiveUI.Extensions/Operators/ObserveOnObservable.cs b/src/ReactiveUI.Extensions/Operators/ObserveOnObservable.cs new file mode 100644 index 0000000..2310f16 --- /dev/null +++ b/src/ReactiveUI.Extensions/Operators/ObserveOnObservable.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using ReactiveUI.Extensions.Internal; + +namespace ReactiveUI.Extensions.Operators; + +/// +/// Marshals every source notification onto the supplied , preserving order. +/// Replaces the System.Reactive.Linq.Observable.ObserveOn delegation behind the sync +/// ObserveOnSafe / ObserveOnIf helpers with our own queue-and-single-drain marshaller: +/// notifications are enqueued and a single drain pass is scheduled per burst (rather than one +/// scheduled action per item). The shared queue / gate / drain machinery lives in +/// ; this sink only carries the forward-everything drain handling. +/// +/// The element type of the source sequence. +/// The source observable. +/// The scheduler every notification is delivered on. +internal sealed class ObserveOnObservable(IObservable source, IScheduler scheduler) : IObservable +{ + /// + public IDisposable Subscribe(IObserver observer) + { + InvalidOperationExceptionHelper.ThrowIfNull(source); + InvalidOperationExceptionHelper.ThrowIfNull(scheduler); + ArgumentExceptionHelper.ThrowIfNull(observer); + + // The immediate scheduler runs scheduled work inline on the calling thread, so the + // queue-and-drain machinery would be pure overhead: forward straight through. + if (ReferenceEquals(scheduler, ImmediateScheduler.Instance)) + { + return source.Subscribe(observer); + } + + var sink = new ObserveOnSink(observer, scheduler); + sink.AttachSourceSubscription(source.Subscribe(sink)); + return sink; + } + + /// + /// Single observer that queues upstream notifications and drains them on the scheduler thread in + /// FIFO order. Terminal notifications travel through the same queue so they never overtake + /// still-queued values. + /// + private sealed class ObserveOnSink : IObserver, IDisposable, IDrainTarget + { + /// The downstream observer. + private readonly IObserver _downstream; + + /// Shared queue / gate / scheduled-drain machinery. + private readonly ScheduledDrainState _state; + + /// Initializes a new instance of the class. + /// The downstream observer. + /// The scheduler notifications are delivered on. + public ObserveOnSink(IObserver downstream, IScheduler scheduler) + { + _downstream = downstream; + _state = new ScheduledDrainState(scheduler, this); + } + + /// Records the upstream subscription so can tear it down. + /// The upstream subscription handle. + public void AttachSourceSubscription(IDisposable subscription) => _state.Attach(subscription); + + /// + public void OnNext(T value) => _state.EnqueueNext(value); + + /// + public void OnError(Exception error) => _state.EnqueueError(error); + + /// + public void OnCompleted() => _state.EnqueueCompleted(); + + /// + public void Dispose() => _state.BeginDispose()?.Dispose(); + + /// + void IDrainTarget.Drain() + { + while (_state.TryDequeue(out var notification)) + { + switch (notification.Kind) + { + case DrainNotificationKind.Next: + { + _downstream.OnNext(notification.Value); + break; + } + + case DrainNotificationKind.Error: + { + _state.Terminate(); + _downstream.OnError(notification.Error!); + return; + } + + default: + { + // DrainNotificationKind has only three values; the discard arm absorbs Completed. + _state.Terminate(); + _downstream.OnCompleted(); + return; + } + } + } + } + } +} diff --git a/src/ReactiveUI.Extensions/Operators/RunAllObservable.cs b/src/ReactiveUI.Extensions/Operators/RunAllObservable.cs index 175a84f..3b1148c 100644 --- a/src/ReactiveUI.Extensions/Operators/RunAllObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/RunAllObservable.cs @@ -41,9 +41,10 @@ public IDisposable Subscribe(IObserver observer) } /// - /// Stateful observer that walks the source list sequentially. Each source's - /// values are ignored; on OnCompleted the next source is subscribed. - /// When all sources have completed, emits and completes. + /// Stateful observer that walks the source list sequentially. The sink subscribes itself + /// directly to each source — its own sets a + /// per-iteration flag the surrounding loop reads to decide whether to advance. This + /// replaces the previous probe-observer-per-iteration allocation pattern. /// /// The downstream observer. /// The source list to walk. @@ -63,6 +64,12 @@ private sealed class Sink( /// Guards against re-entrant calls. private bool _looping; + /// Per-iteration latch (0 = pending, 1 = terminated). Set by + /// when a source terminates synchronously during Subscribe; read by the surrounding + /// loop in . Accessed via so it crosses the + /// method boundary safely without needing a separate probe-observer allocation per iteration. + private int _iterationTerminated; + /// public void OnNext(Unit value) { @@ -91,7 +98,8 @@ public void OnCompleted() if (_looping) { - // Sync-completion is captured by the probe in RunNext; nothing more to do here. + // Inside the loop the surrounding RunNext reads _iterationTerminated; no recursion. + Volatile.Write(ref _iterationTerminated, 1); return; } @@ -117,11 +125,11 @@ internal void RunNext() while (!_done && _index < sources.Count) { var source = sources[_index++]; - var probe = new CompletionFlagObserver(this); - var sub = source.Subscribe(probe); + Volatile.Write(ref _iterationTerminated, 0); + var sub = source.Subscribe(this); Interlocked.Exchange(ref _currentSubscription, sub); - if (!probe.Completed) + if (Volatile.Read(ref _iterationTerminated) == 0) { return; } @@ -132,6 +140,16 @@ internal void RunNext() _looping = false; } + CompleteRun(); + } + + /// Emits the terminal and completes once all sources have run. + /// The already-done early-out is only reachable when a concurrent dispose latches between the + /// loop exit and this call; this small completion shell is excluded from coverage as race-only while the + /// trampoline loop in stays covered. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void CompleteRun() + { if (_done) { return; @@ -141,35 +159,5 @@ internal void RunNext() downstream.OnNext(Unit.Default); downstream.OnCompleted(); } - - /// - /// Forwarding that records whether OnError or - /// OnCompleted arrived synchronously during Subscribe. Replaces the - /// prior _syncCompleted field on so the flag is per-iteration - /// state rather than instance state. - /// - /// The wrapped sink that receives forwarded notifications. - private sealed class CompletionFlagObserver(IObserver inner) : IObserver - { - /// Gets a value indicating whether a terminal notification was observed. - public bool Completed { get; private set; } - - /// - public void OnNext(Unit value) => inner.OnNext(value); - - /// - public void OnError(Exception error) - { - Completed = true; - inner.OnError(error); - } - - /// - public void OnCompleted() - { - Completed = true; - inner.OnCompleted(); - } - } } } diff --git a/src/ReactiveUI.Extensions/Operators/ScheduledSourceObservable.cs b/src/ReactiveUI.Extensions/Operators/ScheduledSourceObservable.cs index 8d7a0a6..99379c9 100644 --- a/src/ReactiveUI.Extensions/Operators/ScheduledSourceObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/ScheduledSourceObservable.cs @@ -55,6 +55,42 @@ public IDisposable Subscribe(IObserver observer) return _source.Subscribe(sink); } + /// + /// Carries the per-emission state into the scheduled callback so the + /// scheduler lambda does not capture any fields. A + /// so it rides inside the + /// scheduler's work item by value rather than as a separate per-emission heap + /// allocation. + /// + /// The downstream observer. + /// The value to emit. + /// The optional transform. + /// The optional side-effect. + private readonly record struct EmitState( + IObserver Observer, + T Value, + Func? Transform, + Action? Action) + { + /// + /// Applies the optional side-effect and transform, then emits the value + /// to the captured observer. + /// + public void Emit() + { + try + { + Action?.Invoke(Value); + var emitted = Transform is null ? Value : Transform(Value); + Observer.OnNext(emitted); + } + catch (Exception error) + { + Observer.OnError(error); + } + } + } + /// /// Per-value sink that captures the configured scheduling parameters once /// and schedules each through the configured @@ -109,37 +145,4 @@ public void OnCompleted() // pattern silently dropped completion. Preserving that behaviour. } } - - /// - /// Carries the per-emission state into the scheduled callback so the - /// scheduler lambda does not capture any fields. - /// - /// The downstream observer. - /// The value to emit. - /// The optional transform. - /// The optional side-effect. - private sealed class EmitState( - IObserver observer, - T value, - Func? transform, - Action? action) - { - /// - /// Applies the optional side-effect and transform, then emits the value - /// to the captured observer. - /// - public void Emit() - { - try - { - action?.Invoke(value); - var emitted = transform is null ? value : transform(value); - observer.OnNext(emitted); - } - catch (Exception error) - { - observer.OnError(error); - } - } - } } diff --git a/src/ReactiveUI.Extensions/Operators/SelectManyThenObservable.cs b/src/ReactiveUI.Extensions/Operators/SelectManyThenObservable.cs index e025b6a..068ae39 100644 --- a/src/ReactiveUI.Extensions/Operators/SelectManyThenObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/SelectManyThenObservable.cs @@ -34,36 +34,56 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new SourceObserver(observer, first, second)); } - /// Receives the source value and subscribes to the first projection. - /// The downstream observer. - /// First projection delegate. - /// Second projection delegate. - private sealed class SourceObserver( - IObserver downstream, - Func> first, - Func> second) : IObserver + /// Receives the source value and subscribes to the first projection. Holds a single + /// reusable created at subscribe time — the mid observer captures + /// only downstream and second, so the same instance handles every source emission. + private sealed class SourceObserver : IObserver { + /// The downstream observer that ultimately receives values. + private readonly IObserver _downstream; + + /// First projection delegate. + private readonly Func> _first; + + /// Pre-allocated intermediate observer shared across every source emission. + private readonly MidObserver _midObserver; + + /// Initializes a new instance of the class and primes the reusable mid observer. + /// The downstream observer. + /// First projection delegate. + /// Second projection delegate. + public SourceObserver( + IObserver downstream, + Func> first, + Func> second) + { + _downstream = downstream; + _first = first; + _midObserver = new MidObserver(downstream, second); + } + /// public void OnNext(TSource value) { try { - first(value).Subscribe(new MidObserver(downstream, second)); + _first(value).Subscribe(_midObserver); } catch (Exception ex) { - downstream.OnError(ex); + _downstream.OnError(ex); } } /// - public void OnError(Exception error) => downstream.OnError(error); + public void OnError(Exception error) => _downstream.OnError(error); /// - public void OnCompleted() => downstream.OnCompleted(); + public void OnCompleted() => _downstream.OnCompleted(); } - /// Receives the intermediate value and subscribes to the second projection. + /// Receives the intermediate value, applies second, and subscribes the resulting + /// observable directly to downstream — no separate final-stage observer needed. /// The downstream observer. /// Second projection delegate. private sealed class MidObserver( @@ -75,7 +95,7 @@ public void OnNext(TMid value) { try { - second(value).Subscribe(new FinalObserver(downstream)); + second(value).Subscribe(downstream); } catch (Exception ex) { @@ -89,18 +109,4 @@ public void OnNext(TMid value) /// public void OnCompleted() => downstream.OnCompleted(); } - - /// Forwards the final result to downstream. - /// The downstream observer. - private sealed class FinalObserver(IObserver downstream) : IObserver - { - /// - public void OnNext(TResult value) => downstream.OnNext(value); - - /// - public void OnError(Exception error) => downstream.OnError(error); - - /// - public void OnCompleted() => downstream.OnCompleted(); - } } diff --git a/src/ReactiveUI.Extensions/Operators/ShuffleObservable.cs b/src/ReactiveUI.Extensions/Operators/ShuffleObservable.cs index 1865af5..0de78ad 100644 --- a/src/ReactiveUI.Extensions/Operators/ShuffleObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/ShuffleObservable.cs @@ -2,17 +2,15 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Security.Cryptography; +using System.Diagnostics.CodeAnalysis; using ReactiveUI.Extensions.Internal; -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER -using System.Buffers.Binary; -#endif namespace ReactiveUI.Extensions.Operators; /// -/// Operator that randomly shuffles arrays emitted by the source. -/// Replaces the closure-based implementation in ReactiveExtensions.Shuffle. +/// Operator that randomly shuffles arrays emitted by the source. The shuffle is not +/// cryptographically secure — callers needing crypto-grade randomness should compose +/// themselves. /// /// The array element type. /// The source observable emitting arrays. @@ -26,14 +24,16 @@ public IDisposable Subscribe(IObserver observer) return source.Subscribe(new ShuffleObserver(observer)); } - /// - /// Observer that shuffles arrays. - /// + /// Observer that shuffles arrays in place. /// The downstream observer receiving shuffled arrays. + [SuppressMessage("Security", "CA5394:Do not use insecure randomness", Justification = "Shuffle is non-cryptographic; Random is faster.")] private sealed class ShuffleObserver(IObserver downstream) : IObserver { - /// Reusable random number generator. - private readonly RandomNumberGenerator _random = RandomNumberGenerator.Create(); +#if !NET8_0_OR_GREATER + /// Per-thread used by the netfx fallback path. + [ThreadStatic] + private static Random? _threadRandom; +#endif /// public void OnNext(T[] value) @@ -44,48 +44,39 @@ public void OnNext(T[] value) return; } -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - Span buffer = stackalloc byte[sizeof(uint)]; -#else - var buffer = new byte[sizeof(uint)]; -#endif - var n = value.Length; - while (n > 1) - { - n--; - var maxExclusive = (uint)(n + 1); - uint val; - var limit = uint.MaxValue - (uint.MaxValue % maxExclusive); - do - { - _random.GetBytes(buffer); -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - val = BinaryPrimitives.ReadUInt32LittleEndian(buffer); +#if NET8_0_OR_GREATER + Random.Shared.Shuffle(value); #else - val = BitConverter.ToUInt32(buffer, 0); + ShuffleInPlace(value); #endif - } - while (val >= limit); - - var k = (int)(val % maxExclusive); - (value[n], value[k]) = (value[k], value[n]); - } downstream.OnNext(value); } /// - public void OnError(Exception error) - { - _random.Dispose(); - downstream.OnError(error); - } + public void OnError(Exception error) => downstream.OnError(error); /// - public void OnCompleted() + public void OnCompleted() => downstream.OnCompleted(); + +#if !NET8_0_OR_GREATER + /// Fisher-Yates over a per-thread for targets without Random.Shuffle. + /// The array to shuffle in place. + private static void ShuffleInPlace(T[] array) { - _random.Dispose(); - downstream.OnCompleted(); + var random = _threadRandom; + if (random is null) + { + random = new Random(); + _threadRandom = random; + } + + for (var n = array.Length - 1; n > 0; n--) + { + var k = random.Next(n + 1); + (array[n], array[k]) = (array[k], array[n]); + } } +#endif } } diff --git a/src/ReactiveUI.Extensions/Operators/SubscribeAsyncObservable.cs b/src/ReactiveUI.Extensions/Operators/SubscribeAsyncObservable.cs index f759ac1..5b3db12 100644 --- a/src/ReactiveUI.Extensions/Operators/SubscribeAsyncObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/SubscribeAsyncObservable.cs @@ -27,7 +27,7 @@ internal sealed class SubscribeAsyncObservable : IDisposable private readonly IDisposable _subscription; /// The asynchronous element handler. - private readonly Func _onNext; + private readonly Func _onNext; /// The error handler. private readonly Action? _onError; @@ -53,7 +53,7 @@ internal sealed class SubscribeAsyncObservable : IDisposable /// The completion handler. public SubscribeAsyncObservable( IObservable source, - Func onNext, + Func onNext, Action? onError = null, Action? onCompleted = null) { diff --git a/src/ReactiveUI.Extensions/Operators/SynchronizeAsyncObservable.cs b/src/ReactiveUI.Extensions/Operators/SynchronizeAsyncObservable.cs index b0f75ce..f7de29b 100644 --- a/src/ReactiveUI.Extensions/Operators/SynchronizeAsyncObservable.cs +++ b/src/ReactiveUI.Extensions/Operators/SynchronizeAsyncObservable.cs @@ -103,14 +103,77 @@ public void Dispose() } /// - /// Processes the value asynchronously. + /// Processes the value. Pushes (value, signal) downstream and waits for the consumer + /// to dispose the signal. The fast path (consumer disposes synchronously inside OnNext) + /// returns a completed task without allocating a state machine or ; + /// the slow path (consumer defers disposal) lazily promotes the signal to a TCS-backed gate. /// /// The value to process. /// A representing the asynchronous operation. - private async Task ProcessAsync(T value) + private Task ProcessAsync(T value) { - using var continuation = new Continuation(); - await continuation.Lock(value, downstream).ConfigureAwait(false); + var signal = new SyncSignal(); + downstream.OnNext((value, signal)); + return signal.WaitForDisposeAsync(); + } + + /// + /// Per-emission gate: the downstream receives this handle as Sync. The producer + /// calls after pushing the value; synchronous disposal + /// short-circuits to with no TCS allocation. Late + /// (asynchronous) disposal lazily allocates a single . + /// + private sealed class SyncSignal : IDisposable + { + /// The lazily-created completion source; only allocated on the slow path. + private TaskCompletionSource? _tcs; + + /// Latches to 1 on the first dispose so signalling is idempotent. + private int _disposed; + + /// Returns the task the producer should await before completing the emission. + /// The producer calls this exactly once per signal, so the TCS is published with a plain + /// volatile write rather than a compare-exchange. + /// A completed task if the consumer already disposed; otherwise the lazily-allocated TCS task. + public Task WaitForDisposeAsync() + { + if (Volatile.Read(ref _disposed) == 1) + { + return Task.CompletedTask; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Volatile.Write(ref _tcs, tcs); + CompleteIfDisposedRaced(tcs); + return tcs.Task; + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + Volatile.Read(ref _tcs)?.TrySetResult(); + } + + /// Self-completes the just-published TCS if a dispose raced ahead of the publish and could + /// not see it, so the producer's await never hangs. + /// The completion source published for this signal. + /// The set-result is only taken when a concurrent dispose latches between the publish and + /// this re-check; isolated here and excluded from coverage as race-only. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + private void CompleteIfDisposedRaced(TaskCompletionSource tcs) + { + if (Volatile.Read(ref _disposed) != 1) + { + return; + } + + tcs.TrySetResult(); + } } } } diff --git a/src/ReactiveUI.Extensions/ReactiveExtensions.cs b/src/ReactiveUI.Extensions/ReactiveExtensions.cs index 278052f..a0edd42 100644 --- a/src/ReactiveUI.Extensions/ReactiveExtensions.cs +++ b/src/ReactiveUI.Extensions/ReactiveExtensions.cs @@ -7,7 +7,6 @@ using System.Linq.Expressions; using System.Reactive; using System.Reactive.Concurrency; -using System.Reactive.Linq; using System.Text.RegularExpressions; using ReactiveUI.Extensions.Internal; using ReactiveUI.Extensions.Internal.Disposables; @@ -272,7 +271,7 @@ public static void OnNext(this IObserver observer, params T[] events) /// The source sequence whose callbacks happen on the specified scheduler. public static IObservable ObserveOnSafe(this IObservable source, IScheduler? scheduler) => - scheduler == null ? source : source.ObserveOn(scheduler); + scheduler == null ? source : new ObserveOnObservable(source, scheduler); /// /// Conditionally switch schedulers. @@ -285,7 +284,7 @@ public static IObservable public static IObservable ObserveOnIf( this IObservable source, bool condition, - IScheduler scheduler) => condition ? source.ObserveOn(scheduler) : source; + IScheduler scheduler) => condition ? new ObserveOnObservable(source, scheduler) : source; /// /// Conditionally switch schedulers. @@ -300,7 +299,9 @@ public static IObservable ObserveOnIf( this IObservable source, bool condition, IScheduler trueScheduler, - IScheduler falseScheduler) => condition ? source.ObserveOn(trueScheduler) : source.ObserveOn(falseScheduler); + IScheduler falseScheduler) => condition + ? new ObserveOnObservable(source, trueScheduler) + : new ObserveOnObservable(source, falseScheduler); /// /// Conditionally switch schedulers based on a reactive condition. @@ -869,7 +870,7 @@ public static IObservable TakeUntil( /// object used to unsubscribe from the observable sequence. public static IDisposable SubscribeSynchronous( this IObservable source, - Func onNext, + Func onNext, Action onError, Action onCompleted) => new SubscribeAsyncObservable(source, onNext, onError, onCompleted); @@ -884,7 +885,7 @@ public static IDisposable SubscribeSynchronous( /// object used to unsubscribe from the observable sequence. public static IDisposable SubscribeSynchronous( this IObservable source, - Func onNext, + Func onNext, Action onError) => new SubscribeAsyncObservable(source, onNext, onError); @@ -899,7 +900,7 @@ public static IDisposable SubscribeSynchronous( /// or or is null. public static IDisposable SubscribeSynchronous( this IObservable source, - Func onNext, + Func onNext, Action onCompleted) => new SubscribeAsyncObservable(source, onNext, onCompleted: onCompleted); @@ -910,7 +911,7 @@ public static IDisposable SubscribeSynchronous( /// Observable sequence to subscribe to. /// Action to invoke for each element in the observable sequence. /// object used to unsubscribe from the observable sequence. - public static IDisposable SubscribeSynchronous(this IObservable source, Func onNext) => + public static IDisposable SubscribeSynchronous(this IObservable source, Func onNext) => new SubscribeAsyncObservable(source, onNext); /// @@ -946,7 +947,7 @@ public static IObservable SwitchIfEmpty( "Roslynator", "RCS1047:Non-asynchronous method name should not end with \'Async\'", Justification = "This is an existing method")] - public static IDisposable SubscribeAsync(this IObservable source, Func onNext) => + public static IDisposable SubscribeAsync(this IObservable source, Func onNext) => new SubscribeAsyncObservable(source, onNext); /// @@ -963,7 +964,7 @@ public static IDisposable SubscribeAsync(this IObservable source, Func(this IObservable source, Func onNext, Action onCompleted) => + public static IDisposable SubscribeAsync(this IObservable source, Func onNext, Action onCompleted) => new SubscribeAsyncObservable(source, onNext, onCompleted: onCompleted); /// @@ -982,7 +983,7 @@ public static IDisposable SubscribeAsync(this IObservable source, Func( this IObservable source, - Func onNext, + Func onNext, Action onError) => new SubscribeAsyncObservable(source, onNext, onError); @@ -1003,7 +1004,7 @@ public static IDisposable SubscribeAsync( Justification = "This is an existing method")] public static IDisposable SubscribeAsync( this IObservable source, - Func onNext, + Func onNext, Action onError, Action onCompleted) => new SubscribeAsyncObservable(source, onNext, onError, onCompleted); @@ -1240,6 +1241,19 @@ public static (IObservable Observable, IObserver Observer) ToReadOnlyBehav /// A Task of T. public static Task ToHotTask(this IObservable source) => FirstAsTaskHelper.FirstAsTask(source); + /// + /// Convert an observable to a that starts immediately. Backed by a + /// pooled implementation, so + /// steady-state callers pay no allocations after the per-type pool warms up. Prefer this over + /// when the call site can consume a + /// (single await, no caching, no WhenAll). + /// + /// The type. + /// The source. + /// A that completes with the first value, faults on source error, or faults on empty completion. + public static ValueTask ToHotValueTask(this IObservable source) => + FirstAsValueTaskHelper.FirstAsValueTask(source); + /// /// Convert a property getter into an observable that emits on change. /// @@ -1483,7 +1497,7 @@ public static IObservable WaitUntil(this IObservable source, FuncAn IObservable of T. public static IObservable DropIfBusy( this IObservable source, - Func asyncAction) => + Func asyncAction) => new DropIfBusyObservable(source, asyncAction); /// diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncDisposablesBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncDisposablesBenchmarks.cs new file mode 100644 index 0000000..aab1cd4 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncDisposablesBenchmarks.cs @@ -0,0 +1,266 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async.Disposables; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Steady-state cost of the three async disposable primitives: +/// , , +/// . Each benchmark drives the primitive's +/// canonical add / set / clear / dispose flow over a no-op child disposable; the runs lock +/// in the per-operation overhead and surface any unexpected per-call allocations. Also covers +/// the composite's read-side inspection surface (Contains / CopyTo / +/// GetEnumerator / Clear) and the single-assignment GetDisposable accessor. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class AsyncDisposablesBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallOperationCount = 100; + + /// High end of the parameter sweep. + private const int LargeOperationCount = 1_000; + + /// Reusable no-op child disposable used by every set/add operation. + private static readonly NoopAsyncDisposable _child = new(); + + /// Distinct children populating the composite for the read-side inspection benchmarks. + private static readonly NoopAsyncDisposable[] _children = [new(), new(), new(), new()]; + + /// Pre-populated composite for the read-only inspection benchmarks. + private CompositeDisposableAsync _populatedComposite = null!; + + /// Composite reused by the Add → Clear benchmark. + private CompositeDisposableAsync _clearComposite = null!; + + /// Single-assignment with an assigned disposable for the GetDisposable benchmark. + private SingleAssignmentDisposableAsync _populatedSingle = null!; + + /// Reused destination buffer for the CopyTo benchmark. + private IAsyncDisposable[] _copyToBuffer = null!; + + /// Gets or sets the number of operations per benchmark invocation. + [Params(SmallOperationCount, LargeOperationCount)] + public int OperationCount { get; set; } + + /// Builds the pre-populated instances used by the inspection benchmarks. + /// A task that completes once every instance is populated. + [GlobalSetup] + public async Task SetupAsync() + { + _populatedComposite = new CompositeDisposableAsync(); + for (var i = 0; i < _children.Length; i++) + { + await _populatedComposite.AddAsync(_children[i]).ConfigureAwait(false); + } + + _clearComposite = new CompositeDisposableAsync(); + _populatedSingle = new SingleAssignmentDisposableAsync(); + await _populatedSingle.SetDisposableAsync(_child).ConfigureAwait(false); + _copyToBuffer = new IAsyncDisposable[_children.Length]; + } + + /// Disposes the pre-populated instances. + /// A task that completes once teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _populatedComposite.DisposeAsync().ConfigureAwait(false); + await _clearComposite.DisposeAsync().ConfigureAwait(false); + await _populatedSingle.DisposeAsync().ConfigureAwait(false); + } + + /// Loops Add → Dispose cycles on fresh instances. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task CompositeDisposable_AddAndDispose() + { + for (var i = 0; i < OperationCount; i++) + { + var composite = new CompositeDisposableAsync(); + await composite.AddAsync(_child).ConfigureAwait(false); + await composite.DisposeAsync().ConfigureAwait(false); + } + } + + /// Drives Add → Remove → Dispose cycles on a long-lived composite. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task CompositeDisposable_AddRemoveSteadyState() + { + var composite = new CompositeDisposableAsync(); + for (var i = 0; i < OperationCount; i++) + { + await composite.AddAsync(_child).ConfigureAwait(false); + _ = await composite.Remove(_child).ConfigureAwait(false); + } + + await composite.DisposeAsync().ConfigureAwait(false); + } + + /// Loops Set → Dispose cycles on fresh instances. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task SerialDisposable_SetAndDispose() + { + for (var i = 0; i < OperationCount; i++) + { + var serial = new SerialDisposableAsync(); + await serial.SetDisposableAsync(_child).ConfigureAwait(false); + await serial.DisposeAsync().ConfigureAwait(false); + } + } + + /// Drives Set → Set → Set cycles on a long-lived serial (each set disposes the prior). + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task SerialDisposable_SwapSteadyState() + { + var serial = new SerialDisposableAsync(); + for (var i = 0; i < OperationCount; i++) + { + await serial.SetDisposableAsync(_child).ConfigureAwait(false); + } + + await serial.DisposeAsync().ConfigureAwait(false); + } + + /// Loops Set → Dispose cycles on fresh instances. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task SingleAssignmentDisposable_SetAndDispose() + { + for (var i = 0; i < OperationCount; i++) + { + var single = new SingleAssignmentDisposableAsync(); + await single.SetDisposableAsync(_child).ConfigureAwait(false); + await single.DisposeAsync().ConfigureAwait(false); + } + } + + /// Zero-wrapper equivalent of SerialDisposable_SetAndDispose using + /// against a caller-owned field. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task Slot_SwapAndDispose() + { + IAsyncDisposable? slot = null; + for (var i = 0; i < OperationCount; i++) + { + slot = null; + await DisposableAsyncSlot.SwapAsync(ref slot, _child).ConfigureAwait(false); + await DisposableAsyncSlot.DisposeAsync(ref slot).ConfigureAwait(false); + } + } + + /// Zero-wrapper equivalent of SingleAssignmentDisposable_SetAndDispose. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task Slot_AssignAndDispose() + { + IAsyncDisposable? slot = null; + for (var i = 0; i < OperationCount; i++) + { + slot = null; + await DisposableAsyncSlot.AssignAsync(ref slot, _child).ConfigureAwait(false); + await DisposableAsyncSlot.DisposeAsync(ref slot).ConfigureAwait(false); + } + } + + /// Loops Contains hits against the populated composite. + [Benchmark] + public void CompositeDisposable_Contains() + { + for (var i = 0; i < OperationCount; i++) + { + _ = _populatedComposite.Contains(_children[0]); + } + } + + /// Loops CopyTo calls into a reused buffer. + [Benchmark] + public void CompositeDisposable_CopyTo() + { + for (var i = 0; i < OperationCount; i++) + { + _populatedComposite.CopyTo(_copyToBuffer, 0); + } + } + + /// Loops snapshot enumerations of the populated composite. + [Benchmark] + public void CompositeDisposable_Enumerate() + { + for (var i = 0; i < OperationCount; i++) + { + foreach (var item in _populatedComposite) + { + _ = item; + } + } + } + + /// Loops Add → Clear cycles on a reused composite. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task CompositeDisposable_AddAndClear() + { + for (var i = 0; i < OperationCount; i++) + { + await _clearComposite.AddAsync(_child).ConfigureAwait(false); + await _clearComposite.Clear().ConfigureAwait(false); + } + } + + /// Loops GetDisposable reads on an assigned single-assignment. + [Benchmark] + public void SingleAssignmentDisposable_GetDisposable() + { + for (var i = 0; i < OperationCount; i++) + { + _ = _populatedSingle.GetDisposable(); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } + + /// No-op async disposable shared across every benchmark. + [SuppressMessage( + "Critical Code Smell", + "S1186:Methods should not be empty", + Justification = "Empty no-op is the benchmark's whole point — we want zero work in DisposeAsync.")] + private sealed class NoopAsyncDisposable : IAsyncDisposable + { + /// + public ValueTask DisposeAsync() => default; + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncFactoryBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncFactoryBenchmarks.cs new file mode 100644 index 0000000..514db69 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncFactoryBenchmarks.cs @@ -0,0 +1,137 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Disposables; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-subscribe cost of the async observable factories that hadn't been benchmarked: +/// Defer, Create, CreateAsBackgroundJob, Empty, Never. +/// Locks in the per-instance allocation profile each factory imposes. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class AsyncFactoryBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallInvocationCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeInvocationCount = 10_000; + + /// Pre-built inner observable returned by the Defer factory delegate. + private static readonly IObservableAsync _innerObservable = ObservableAsync.Return(0); + + /// Static factory delegate captured once; the deferred path subscribes through it per call. + private static readonly Func> _deferFactory = static () => _innerObservable; + + /// Sink used by every drain. + private readonly BenchmarkNoopObserver _sink = new(); + + /// Gets or sets the number of subscribe + drain cycles per benchmark invocation. + [Params(SmallInvocationCount, LargeInvocationCount)] + public int InvocationCount { get; set; } + + /// Tears the sink down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() => await _sink.DisposeAsync().ConfigureAwait(false); + + /// Loops Defer subscribe-and-drain cycles. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task Defer_SubscribeAndDrain() + { + for (var i = 0; i < InvocationCount; i++) + { + await using var sub = await ObservableAsync.Defer(_deferFactory).SubscribeAsync(_sink, default).ConfigureAwait(false); + } + } + + /// Loops Create subscribe cycles. The factory delegate is a static no-capture that + /// emits one value and returns . + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task Create_SubscribeAndDrain() + { + for (var i = 0; i < InvocationCount; i++) + { + await using var sub = await ObservableAsync.Create(static async (observer, ct) => + { + await observer.OnNextAsync(0, ct).ConfigureAwait(false); + await observer.OnCompletedAsync(Result.Success).ConfigureAwait(false); + return DisposableAsync.Empty; + }).SubscribeAsync(_sink, default).ConfigureAwait(false); + } + } + + /// Loops CreateAsBackgroundJob subscribe cycles. The deferred body runs as a + /// fire-and-forget job; the benchmark measures the per-Subscribe machinery, not the job body. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task CreateAsBackgroundJob_SubscribeAndDrain() + { + for (var i = 0; i < InvocationCount; i++) + { + await using var sub = await ObservableAsync.CreateAsBackgroundJob(static async (observer, ct) => + { + await observer.OnNextAsync(0, ct).ConfigureAwait(false); + await observer.OnCompletedAsync(Result.Success).ConfigureAwait(false); + }).SubscribeAsync(_sink, default).ConfigureAwait(false); + } + } + + /// Loops Empty subscribe cycles. Empty is a singleton instance; the benchmark + /// confirms zero per-cycle allocation overhead. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task Empty_SubscribeAndDrain() + { + for (var i = 0; i < InvocationCount; i++) + { + await using var sub = await ObservableAsync.Empty().SubscribeAsync(_sink, default).ConfigureAwait(false); + } + } + + /// Loops Never subscribe / dispose cycles. Never emits nothing; the benchmark + /// measures the per-subscribe + per-dispose overhead without any emission cost. + /// A task that completes when every cycle has finished. + [Benchmark] + public async Task Never_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + await using var sub = await ObservableAsync.Never().SubscribeAsync(_sink, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncMixinsBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncMixinsBenchmarks.cs new file mode 100644 index 0000000..cf7f8d4 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncMixinsBenchmarks.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-call allocation profile for the public async mixin helpers: +/// , , +/// and . Each is a thin factory; the bench +/// loops them to surface the per-construction cost. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class AsyncMixinsBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallInvocationCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeInvocationCount = 10_000; + + /// Identity mapper used for MapValues; static so no per-call capture allocation. + private static readonly Func, IObservableAsync> _identityMapper = static x => x; + + /// No-op disposable reused by every ToDisposableAsync bench call. + private readonly NoopDisposable _disposable = new(); + + /// Subject under test for the AsObserverAsync / MapValues calls. + private SerialStatelessSubjectAsync _subject = null!; + + /// Gets or sets the number of invocations per benchmark iteration. + [Params(SmallInvocationCount, LargeInvocationCount)] + public int InvocationCount { get; set; } + + /// Constructs the subject reused across mixin calls. + [GlobalSetup] + public void Setup() => _subject = new SerialStatelessSubjectAsync(); + + /// Tears the subject down asynchronously. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() => await _subject.DisposeAsync().ConfigureAwait(false); + + /// Loops over the same subject. + [Benchmark] + public void AsObserverAsync_PerCall() + { + for (var i = 0; i < InvocationCount; i++) + { + _ = _subject.AsObserverAsync(); + } + } + + /// Loops with a static identity mapper. + [Benchmark] + public void MapValues_PerCall() + { + for (var i = 0; i < InvocationCount; i++) + { + _ = _subject.MapValues(_identityMapper); + } + } + + /// Loops over a no-op disposable. + [Benchmark] + public void ToDisposableAsync_PerCall() + { + for (var i = 0; i < InvocationCount; i++) + { + _ = _disposable.ToDisposableAsync(); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _disposable.Dispose(); + CleanupAsync().GetAwaiter().GetResult(); + } + + /// No-op reused across ToDisposableAsync bench calls. + private sealed class NoopDisposable : IDisposable + { + /// + public void Dispose() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncOperatorCoverageBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncOperatorCoverageBenchmarks.cs new file mode 100644 index 0000000..331484e --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncOperatorCoverageBenchmarks.cs @@ -0,0 +1,191 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Steady-state per-emission cost of a cluster of async operators that hadn't been benchmarked: +/// OnDispose, OnErrorResumeAsFailure, RefCount, TakeWhile, +/// WaitCompletionAsync, plus the observer Wrap factory. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class AsyncOperatorCoverageBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Static no-op dispose action shared by the OnDispose pipeline. + private static readonly Action _noopDisposeAction = static () => { }; + + /// Shared no-op sink. + private readonly BenchmarkNoopObserver _sink = new(); + + /// Source feeding the OnDispose pipeline. + private SerialStatelessSubjectAsync _onDisposeSource = null!; + + /// Subscription on the OnDispose pipeline. + private IAsyncDisposable _onDisposeSubscription = null!; + + /// Source feeding the OnErrorResumeAsFailure pipeline. + private SerialStatelessSubjectAsync _onErrorSource = null!; + + /// Subscription on the OnErrorResumeAsFailure pipeline. + private IAsyncDisposable _onErrorSubscription = null!; + + /// Source feeding the RefCount pipeline. + private SerialStatelessSubjectAsync _refCountSource = null!; + + /// Subscription on the RefCount pipeline (keeps the upstream connected). + private IAsyncDisposable _refCountSubscription = null!; + + /// Source feeding the TakeWhile pipeline. + private SerialStatelessSubjectAsync _takeWhileSource = null!; + + /// Subscription on the TakeWhile pipeline. + private IAsyncDisposable _takeWhileSubscription = null!; + + /// Gets or sets the number of emissions pushed through each per-emission pipeline. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires every long-lived pipeline. + /// A task that completes when setup is done. + [GlobalSetup] + public async Task SetupAsync() + { + _onDisposeSource = new SerialStatelessSubjectAsync(); + _onDisposeSubscription = await _onDisposeSource + .OnDispose(_noopDisposeAction) + .SubscribeAsync(_sink, default).ConfigureAwait(false); + + _onErrorSource = new SerialStatelessSubjectAsync(); + _onErrorSubscription = await _onErrorSource + .OnErrorResumeAsFailure() + .SubscribeAsync(_sink, default).ConfigureAwait(false); + + _refCountSource = new SerialStatelessSubjectAsync(); + var refCount = _refCountSource.Publish().RefCount(); + _refCountSubscription = await refCount.SubscribeAsync(_sink, default).ConfigureAwait(false); + + _takeWhileSource = new SerialStatelessSubjectAsync(); + _takeWhileSubscription = await _takeWhileSource + .TakeWhile(static _ => true) + .SubscribeAsync(_sink, default).ConfigureAwait(false); + } + + /// Tears every pipeline down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _onDisposeSubscription.DisposeAsync().ConfigureAwait(false); + await _onErrorSubscription.DisposeAsync().ConfigureAwait(false); + await _refCountSubscription.DisposeAsync().ConfigureAwait(false); + await _takeWhileSubscription.DisposeAsync().ConfigureAwait(false); + await _onDisposeSource.DisposeAsync().ConfigureAwait(false); + await _onErrorSource.DisposeAsync().ConfigureAwait(false); + await _refCountSource.DisposeAsync().ConfigureAwait(false); + await _takeWhileSource.DisposeAsync().ConfigureAwait(false); + await _sink.DisposeAsync().ConfigureAwait(false); + } + + /// Drives values through the OnDispose pipeline. + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task OnDispose_SteadyState() + { + for (var i = 0; i < EmissionCount; i++) + { + await _onDisposeSource.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// Drives values through the OnErrorResumeAsFailure pipeline (no errors). + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task OnErrorResumeAsFailure_HappyPath() + { + for (var i = 0; i < EmissionCount; i++) + { + await _onErrorSource.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// Drives values through the RefCount pipeline. + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task RefCount_SteadyState() + { + for (var i = 0; i < EmissionCount; i++) + { + await _refCountSource.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// Drives values through the TakeWhile pipeline (predicate always true). + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task TakeWhile_AllPassing() + { + for (var i = 0; i < EmissionCount; i++) + { + await _takeWhileSource.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// Drains single-shot sources via WaitCompletionAsync. + /// A task that completes when every drain has finished. + [Benchmark] + public async Task WaitCompletionAsync_SingleValueSource() + { + for (var i = 0; i < EmissionCount; i++) + { + await ObservableAsync.Return(i).WaitCompletionAsync().ConfigureAwait(false); + } + } + + /// Loops the factory call. + [Benchmark] + public void Wrap_PerCall() + { + for (var i = 0; i < EmissionCount; i++) + { + _ = _sink.Wrap(); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncPrimitivesBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncPrimitivesBenchmarks.cs new file mode 100644 index 0000000..a7cca14 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncPrimitivesBenchmarks.cs @@ -0,0 +1,99 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Concurrency; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Disposables; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the cost of the small public async primitives that have no other benchmark home: +/// Result.Failure, the three AsyncContext.From overloads and GetCurrent, +/// IsSameAsCurrentAsyncContext, Optional.TryGetValue, DisposableAsyncSlot.IsDisposed, +/// UnhandledExceptionHandler.Register, AsyncContext.SwitchContextAsync, and +/// Result.TryThrow. These are construction / inspection helpers, so the numbers are floors that +/// guard against accidental allocation creeping into the hot primitives. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +[SuppressMessage( + "Performance", + "CA1822:Mark members as static", + Justification = "BenchmarkDotNet drives benchmarks through an instance; the methods cannot be static.")] +public class AsyncPrimitivesBenchmarks +{ + /// Cached error so measures the wrap, not the throw. + private static readonly InvalidOperationException Error = new("benchmark"); + + /// Cached synchronization context for the From(SynchronizationContext) overload. + private static readonly SynchronizationContext SyncContext = new(); + + /// Cached async context for the IsSameAsCurrentAsyncContext check. + private static readonly AsyncContext Context = AsyncContext.From(TaskScheduler.Default); + + /// Cached populated optional for the TryGetValue benchmark. + private static readonly Optional SomeValue = new(42); + + /// Cached non-sentinel disposable for the IsDisposed benchmark. + private static readonly IAsyncDisposable LiveSlot = DisposableAsync.Empty; + + /// Constructs a failure from a cached exception. + /// The failure result. + [Benchmark] + public Result ResultFailure_Construct() => Result.Failure(Error); + + /// Builds an from a . + /// The constructed context. + [Benchmark] + public AsyncContext AsyncContextFrom_SynchronizationContext() => AsyncContext.From(SyncContext); + + /// Builds an from a . + /// The constructed context. + [Benchmark] + public AsyncContext AsyncContextFrom_TaskScheduler() => AsyncContext.From(TaskScheduler.Default); + + /// Builds an from an . + /// The constructed context. + [Benchmark] + public AsyncContext AsyncContextFrom_Scheduler() => AsyncContext.From(Scheduler.Default); + + /// Captures the current . + /// The current context. + [Benchmark] + public AsyncContext AsyncContext_GetCurrent() => AsyncContext.GetCurrent(); + + /// Compares a cached context against the current async context. + /// if they match. + [Benchmark] + public bool IsSameAsCurrentAsyncContext_Check() => Context.IsSameAsCurrentAsyncContext(); + + /// Reads the value out of a populated . + /// when a value is present. + [Benchmark] + public bool OptionalTryGetValue_Some() => SomeValue.TryGetValue(out _); + + /// Inspects a live (non-disposed) slot via DisposableAsyncSlot.IsDisposed. + /// if the slot holds the disposed sentinel. + [Benchmark] + public bool DisposableAsyncSlotIsDisposed_Live() => DisposableAsyncSlot.IsDisposed(LiveSlot); + + /// Registers a no-op unhandled-exception handler (replaces the global handler). + [Benchmark] + public void UnhandledExceptionHandler_Register() => UnhandledExceptionHandler.Register(static _ => { }); + + /// Switches onto a cached async context (no forced yielding) and awaits the awaitable. + /// A task that completes when the context switch resolves. + [Benchmark] + public async Task SwitchContextAsync_NoYield() => + await Context.SwitchContextAsync(false, CancellationToken.None); + + /// Invokes TryThrow on a success result (the no-throw fast path). + [Benchmark] + public void ResultTryThrow_Success() => Result.Success.TryThrow(); +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncSelectVariantsBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncSelectVariantsBenchmarks.cs new file mode 100644 index 0000000..4bd0973 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncSelectVariantsBenchmarks.cs @@ -0,0 +1,189 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-emission cost of the async projection operators — SelectAsyncSequential, +/// SelectLatestAsync, SelectAsyncConcurrent, and the two SelectAsync overloads +/// (which delegate to the sequential observable). Each variant runs an +/// Func<TSource, Task<TResult>> projection that resolves against a cached completed +/// task so the benchmark captures the operator's per-emission overhead rather than I/O. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class AsyncSelectVariantsBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 100; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 1_000; + + /// Concurrency level passed to SelectAsyncConcurrent. + private const int Concurrency = 4; + + /// Pre-completed task reused by every projection. + private static readonly Task _completedResult = Task.FromResult(0); + + /// Source for the sequential pipeline. + private readonly Subject _sequentialSource = new(); + + /// Source for the latest pipeline. + private readonly Subject _latestSource = new(); + + /// Source for the concurrent pipeline. + private readonly Subject _concurrentSource = new(); + + /// Source for the SelectAsync(Func<T,Task>) pipeline. + private readonly Subject _selectAsyncSource = new(); + + /// Source for the SelectAsync(Func<T,CancellationToken,Task>) pipeline. + private readonly Subject _selectAsyncCtSource = new(); + + /// Reused sink. + private readonly NoopObserver _sink = new(); + + /// Subscription on the sequential pipeline. + private IDisposable _sequentialSubscription = null!; + + /// Subscription on the latest pipeline. + private IDisposable _latestSubscription = null!; + + /// Subscription on the concurrent pipeline. + private IDisposable _concurrentSubscription = null!; + + /// Subscription on the SelectAsync(Func<T,Task>) pipeline. + private IDisposable _selectAsyncSubscription = null!; + + /// Subscription on the SelectAsync(Func<T,CancellationToken,Task>) pipeline. + private IDisposable _selectAsyncCtSubscription = null!; + + /// Gets or sets the number of emissions pushed per invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires every pipeline. + [GlobalSetup] + public void Setup() + { + _sequentialSubscription = _sequentialSource.SelectAsyncSequential(static _ => _completedResult).Subscribe(_sink); + _latestSubscription = _latestSource.SelectLatestAsync(static _ => _completedResult).Subscribe(_sink); + _concurrentSubscription = _concurrentSource + .SelectAsyncConcurrent(static _ => _completedResult, Concurrency) + .Subscribe(_sink); + _selectAsyncSubscription = _selectAsyncSource.SelectAsync(static _ => _completedResult).Subscribe(_sink); + _selectAsyncCtSubscription = _selectAsyncCtSource.SelectAsync(static (_, _) => _completedResult).Subscribe(_sink); + } + + /// Tears every pipeline down. + [GlobalCleanup] + public void Cleanup() + { + _sequentialSubscription.Dispose(); + _latestSubscription.Dispose(); + _concurrentSubscription.Dispose(); + _selectAsyncSubscription.Dispose(); + _selectAsyncCtSubscription.Dispose(); + _sequentialSource.Dispose(); + _latestSource.Dispose(); + _concurrentSource.Dispose(); + _selectAsyncSource.Dispose(); + _selectAsyncCtSource.Dispose(); + } + + /// Drives values through SelectAsyncSequential. + [Benchmark] + public void SelectAsyncSequential_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _sequentialSource.OnNext(i); + } + } + + /// Drives values through SelectLatestAsync. + [Benchmark] + public void SelectLatestAsync_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _latestSource.OnNext(i); + } + } + + /// Drives values through SelectAsyncConcurrent. + [Benchmark] + public void SelectAsyncConcurrent_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _concurrentSource.OnNext(i); + } + } + + /// Drives values through SelectAsync(Func<T,Task>). + [Benchmark] + public void SelectAsync_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _selectAsyncSource.OnNext(i); + } + } + + /// Drives values through SelectAsync(Func<T,CancellationToken,Task>). + [Benchmark] + public void SelectAsyncWithToken_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _selectAsyncCtSource.OnNext(i); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncToSyncBridgeBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncToSyncBridgeBenchmarks.cs new file mode 100644 index 0000000..96601c0 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/AsyncToSyncBridgeBenchmarks.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission cost of the async→sync bridge +/// (ToObservable in Async/Bridge/ObservableBridgeExtensions.cs): an async subject is +/// bridged into a classic , a synchronous observer is attached, and +/// values are pushed through. This complements the sync→async direction (ToObservableAsync) +/// already covered by the bridge profile benchmarks. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class AsyncToSyncBridgeBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Synchronous no-op sink subscribed to the bridged observable. + private readonly NoopObserver _sink = new(); + + /// Async subject feeding the bridge. + private SerialStatelessSubjectAsync _source = null!; + + /// Subscription returned by the bridged sync observable. + private IDisposable _subscription = null!; + + /// Gets or sets the number of emissions pushed through the bridge per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Builds the bridge and attaches the sync sink. + [GlobalSetup] + public void Setup() + { + _source = new SerialStatelessSubjectAsync(); + _subscription = _source.ToObservable().Subscribe(_sink); + } + + /// Tears the bridge down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + _subscription.Dispose(); + await _source.DisposeAsync().ConfigureAwait(false); + } + + /// Pushes values through the async→sync bridge. + /// A task that completes when every value has been propagated to the sync sink. + [Benchmark] + public async Task AsyncToSyncBridge_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + await _source.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } + + /// No-op synchronous observer used as the bridge's terminal sink. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/BooleanAsyncBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/BooleanAsyncBenchmarks.cs new file mode 100644 index 0000000..f1f8cf6 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/BooleanAsyncBenchmarks.cs @@ -0,0 +1,128 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-emission steady-state cost for the async boolean operators that hadn't been benchmarked: +/// WhereFalse and CombineLatestValuesAreAllFalse. Locks in the zero-alloc baseline +/// on the filter and the per-emission aggregate path. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class BooleanAsyncBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Shared no-op sink. + private readonly BenchmarkNoopObserver _sink = new(); + + /// Source for the WhereFalse pipeline. + private SerialStatelessSubjectAsync _whereFalseSource = null!; + + /// Subscription on the WhereFalse pipeline. + private IAsyncDisposable _whereFalseSubscription = null!; + + /// Inputs for the CombineLatestValuesAreAllFalse pipeline. + private SerialStatelessSubjectAsync _aggregateA = null!; + + /// Second input for the aggregate pipeline. + private SerialStatelessSubjectAsync _aggregateB = null!; + + /// Subscription on the aggregate pipeline. + private IAsyncDisposable _aggregateSubscription = null!; + + /// Gets or sets the number of emissions pushed per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires both pipelines. + /// A task that completes when both pipelines are subscribed. + [GlobalSetup] + public async Task SetupAsync() + { + _whereFalseSource = new SerialStatelessSubjectAsync(); + _whereFalseSubscription = await _whereFalseSource.WhereFalse() + .SubscribeAsync(_sink, default).ConfigureAwait(false); + + _aggregateA = new SerialStatelessSubjectAsync(); + _aggregateB = new SerialStatelessSubjectAsync(); + IObservableAsync[] sources = [_aggregateA, _aggregateB]; + _aggregateSubscription = await sources.CombineLatestValuesAreAllFalse() + .SubscribeAsync(_sink, default).ConfigureAwait(false); + + // Prime both sources so the aggregate has values to combine. + await _aggregateA.OnNextAsync(false, default).ConfigureAwait(false); + await _aggregateB.OnNextAsync(false, default).ConfigureAwait(false); + } + + /// Tears both pipelines down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _whereFalseSubscription.DisposeAsync().ConfigureAwait(false); + await _aggregateSubscription.DisposeAsync().ConfigureAwait(false); + await _whereFalseSource.DisposeAsync().ConfigureAwait(false); + await _aggregateA.DisposeAsync().ConfigureAwait(false); + await _aggregateB.DisposeAsync().ConfigureAwait(false); + await _sink.DisposeAsync().ConfigureAwait(false); + } + + /// Drives all-false values through the WhereFalse pipeline (every emission passes). + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task WhereFalse_AllPassing() + { + for (var i = 0; i < EmissionCount; i++) + { + await _whereFalseSource.OnNextAsync(false, default).ConfigureAwait(false); + } + } + + /// Drives alternating false / false emissions through the aggregate pipeline. + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task CombineLatestValuesAreAllFalse_SteadyState() + { + for (var i = 0; i < EmissionCount; i++) + { + await _aggregateA.OnNextAsync(false, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/BufferUntilCharBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/BufferUntilCharBenchmarks.cs new file mode 100644 index 0000000..5431e43 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/BufferUntilCharBenchmarks.cs @@ -0,0 +1,112 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-character cost of BufferUntil(start, end), which buffers characters +/// between matching delimiters and emits a string on each match. Drives a fixed pattern through +/// the operator (one open-delimiter + payload + close-delimiter per cycle) so the steady-state +/// allocation profile reflects exactly one string emission per cycle. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class BufferUntilCharBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallCycleCount = 100; + + /// High end of the parameter sweep. + private const int LargeCycleCount = 1_000; + + /// Open delimiter character. + private const char OpenDelimiter = '['; + + /// Close delimiter character. + private const char CloseDelimiter = ']'; + + /// Payload character inside each delimited group. + private const char PayloadChar = 'x'; + + /// Source feeding the BufferUntil pipeline. + private readonly Subject _source = new(); + + /// No-op sink absorbing the emitted strings. + private readonly NoopObserver _sink = new(); + + /// Subscription on the BufferUntil pipeline. + private IDisposable _subscription = null!; + + /// Gets or sets the number of open/payload/close cycles pushed per benchmark invocation. + [Params(SmallCycleCount, LargeCycleCount)] + public int CycleCount { get; set; } + + /// Wires the BufferUntil pipeline. + [GlobalSetup] + public void Setup() => _subscription = _source.BufferUntil(OpenDelimiter, CloseDelimiter).Subscribe(_sink); + + /// Tears the pipeline down. + [GlobalCleanup] + public void Cleanup() + { + _subscription.Dispose(); + _source.Dispose(); + } + + /// Drives delimited groups through the BufferUntil pipeline. + [Benchmark] + public void BufferUntil_PerDelimitedGroup() + { + for (var i = 0; i < CycleCount; i++) + { + _source.OnNext(OpenDelimiter); + _source.OnNext(PayloadChar); + _source.OnNext(CloseDelimiter); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op observer used as the terminal sink. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CatchAndIgnoreErrorResumeBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CatchAndIgnoreErrorResumeBenchmarks.cs new file mode 100644 index 0000000..77fa321 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CatchAndIgnoreErrorResumeBenchmarks.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission steady-state cost of CatchAndIgnoreErrorResume on the +/// happy path — every emission forwards verbatim, and the fallback factory is never invoked. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class CatchAndIgnoreErrorResumeBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Shared no-op sink. + private readonly BenchmarkNoopObserver _sink = new(); + + /// Pre-built fallback observable; never subscribed on the happy path. + private readonly IObservableAsync _fallback = ObservableAsync.Return(-1); + + /// Source feeding the CatchAndIgnoreErrorResume pipeline. + private SerialStatelessSubjectAsync _source = null!; + + /// Subscription on the CatchAndIgnoreErrorResume pipeline. + private IAsyncDisposable _subscription = null!; + + /// Gets or sets the number of emissions pushed per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires the pipeline with a non-capturing fallback factory. + /// A task that completes when the pipeline is subscribed. + [GlobalSetup] + public async Task SetupAsync() + { + _source = new SerialStatelessSubjectAsync(); + _subscription = await _source + .CatchAndIgnoreErrorResume(_ => _fallback) + .SubscribeAsync(_sink, default).ConfigureAwait(false); + } + + /// Tears the pipeline down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _subscription.DisposeAsync().ConfigureAwait(false); + await _source.DisposeAsync().ConfigureAwait(false); + await _sink.DisposeAsync().ConfigureAwait(false); + } + + /// Drives values through the CatchAndIgnoreErrorResume pipeline (no errors). + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task CatchAndIgnoreErrorResume_HappyPath() + { + for (var i = 0; i < EmissionCount; i++) + { + await _source.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CatchReturnBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CatchReturnBenchmarks.cs new file mode 100644 index 0000000..03f17a9 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CatchReturnBenchmarks.cs @@ -0,0 +1,130 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission steady-state cost of CatchReturn(T) and +/// CatchReturnUnit — both forward source values verbatim on the happy path; the fallback +/// fires only on error. Locks in the pass-through baseline so future error-handling additions +/// don't accidentally regress the no-error path. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class CatchReturnBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Fallback value used by the typed CatchReturn pipeline (never triggered on the happy path). + private const int Fallback = -1; + + /// Source for the typed CatchReturn pipeline. + private readonly Subject _intSource = new(); + + /// Source for the Unit CatchReturnUnit pipeline. + private readonly Subject _unitSource = new(); + + /// No-op int sink. + private readonly NoopObserver _intSink = new(); + + /// No-op Unit sink. + private readonly NoopObserver _unitSink = new(); + + /// Subscription on the typed CatchReturn pipeline. + private IDisposable _intSubscription = null!; + + /// Subscription on the Unit CatchReturnUnit pipeline. + private IDisposable _unitSubscription = null!; + + /// Gets or sets the number of emissions pushed through each pipeline per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires both CatchReturn pipelines. + [GlobalSetup] + public void Setup() + { + _intSubscription = _intSource.CatchReturn(Fallback).Subscribe(_intSink); + _unitSubscription = _unitSource.CatchReturnUnit().Subscribe(_unitSink); + } + + /// Tears both pipelines down. + [GlobalCleanup] + public void Cleanup() + { + _intSubscription.Dispose(); + _unitSubscription.Dispose(); + _intSource.Dispose(); + _unitSource.Dispose(); + } + + /// Drives values through the typed CatchReturn pipeline (no errors). + [Benchmark] + public void CatchReturn_HappyPath() + { + for (var i = 0; i < EmissionCount; i++) + { + _intSource.OnNext(i); + } + } + + /// Drives Unit values through the CatchReturnUnit pipeline (no errors). + [Benchmark] + public void CatchReturnUnit_HappyPath() + { + for (var i = 0; i < EmissionCount; i++) + { + _unitSource.OnNext(Unit.Default); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op observer used as the terminal sink. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ConcurrentFanOutBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ConcurrentFanOutBenchmarks.cs new file mode 100644 index 0000000..e461493 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ConcurrentFanOutBenchmarks.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-call cost of the observer fan-out helpers: ObserverExtensions.FastForEach +/// (bulk push into a synchronous observer) and the Concurrent.ForwardOn*Concurrently helpers +/// that broadcast a notification to an of async observers. Sinks are +/// no-ops so the benchmark captures the fan-out machinery rather than downstream work. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ConcurrentFanOutBenchmarks : IDisposable +{ + /// Number of observers in the broadcast array. + private const int ObserverCount = 4; + + /// Length of the sequence pushed through FastForEach per call. + private const int SequenceLength = 100; + + /// Sentinel value broadcast by the forward benchmarks. + private const int Value = 42; + + /// Synchronous no-op sink for FastForEach. + private readonly NoopObserver _syncSink = new(); + + /// Cached sequence pushed through FastForEach so its allocation isn't measured. + private readonly int[] _sequence = [.. Enumerable.Range(0, SequenceLength)]; + + /// Cached error reused by the error-resume broadcast. + private readonly InvalidOperationException _error = new("benchmark"); + + /// Async observers targeted by the broadcast benchmarks. + private BenchmarkNoopObserver[] _asyncSinks = null!; + + /// Immutable snapshot handed to the forward helpers. + private ImmutableArray> _observers; + + /// Builds the async observer array. + [GlobalSetup] + public void Setup() + { + _asyncSinks = new BenchmarkNoopObserver[ObserverCount]; + var builder = ImmutableArray.CreateBuilder>(ObserverCount); + for (var i = 0; i < ObserverCount; i++) + { + _asyncSinks[i] = new BenchmarkNoopObserver(); + builder.Add(_asyncSinks[i]); + } + + _observers = builder.ToImmutable(); + } + + /// Tears the async observers down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + for (var i = 0; i < _asyncSinks.Length; i++) + { + await _asyncSinks[i].DisposeAsync().ConfigureAwait(false); + } + } + + /// Pushes a 100-element sequence into a synchronous observer via FastForEach. + [Benchmark] + public void FastForEach_HundredElements() => _syncSink.FastForEach(_sequence); + + /// Broadcasts a single value to four async observers via ForwardOnNextConcurrently. + /// A task that completes when every observer has been notified. + [Benchmark] + public ValueTask ForwardOnNextConcurrently_FourObservers() => + Concurrent.ForwardOnNextConcurrently(_observers, Value, default); + + /// Broadcasts an error to four async observers via ForwardOnErrorResumeConcurrently. + /// A task that completes when every observer has been notified. + [Benchmark] + public ValueTask ForwardOnErrorResumeConcurrently_FourObservers() => + Concurrent.ForwardOnErrorResumeConcurrently(_observers, _error, default); + + /// Broadcasts completion to four async observers via ForwardOnCompletedConcurrently. + /// A task that completes when every observer has been notified. + [Benchmark] + public ValueTask ForwardOnCompletedConcurrently_FourObservers() => + Concurrent.ForwardOnCompletedConcurrently(_observers, Result.Success); + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } + + /// No-op synchronous observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ConcurrentReplayLatestSubjectBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ConcurrentReplayLatestSubjectBenchmarks.cs new file mode 100644 index 0000000..6b008a2 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ConcurrentReplayLatestSubjectBenchmarks.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-emission broadcast cost of with two +/// observers attached. Complements the existing serial-replay benchmarks; locks in the concurrent +/// fan-out path's overhead and surfaces any per-emission allocation. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ConcurrentReplayLatestSubjectBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// First downstream sink. + private readonly BenchmarkNoopObserver _sinkA = new(); + + /// Second downstream sink. + private readonly BenchmarkNoopObserver _sinkB = new(); + + /// The subject under test. + private ConcurrentReplayLatestSubjectAsync _subject = null!; + + /// First subscription. + private IAsyncDisposable _subA = null!; + + /// Second subscription. + private IAsyncDisposable _subB = null!; + + /// Gets or sets the number of emissions pushed per invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires the subject and two observers. + /// A task that completes when both observers are subscribed. + [GlobalSetup] + public async Task SetupAsync() + { + _subject = new ConcurrentReplayLatestSubjectAsync(Optional.Empty); + _subA = await _subject.SubscribeAsync(_sinkA, default).ConfigureAwait(false); + _subB = await _subject.SubscribeAsync(_sinkB, default).ConfigureAwait(false); + } + + /// Tears the subject and subscriptions down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _subA.DisposeAsync().ConfigureAwait(false); + await _subB.DisposeAsync().ConfigureAwait(false); + await _subject.DisposeAsync().ConfigureAwait(false); + await _sinkA.DisposeAsync().ConfigureAwait(false); + await _sinkB.DisposeAsync().ConfigureAwait(false); + } + + /// Drives values through the concurrent broadcast path. + /// A task that completes when every value has been broadcast. + [Benchmark] + public async Task Broadcast_TwoObservers() + { + for (var i = 0; i < EmissionCount; i++) + { + await _subject.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ContinuationLockBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ContinuationLockBenchmarks.cs new file mode 100644 index 0000000..fd3a80d --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ContinuationLockBenchmarks.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// A/B benchmark between (-returning) and +/// (-returning) on the +/// already-locked fast path — exercises the boxed-Task.CompletedTask wrapper that the Task +/// path materializes against the default ValueTask the new overload returns. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ContinuationLockBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallInvocationCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeInvocationCount = 10_000; + + /// Pre-locked Continuation reused by every benchmark call so each invocation hits the + /// already-locked fast path (returns immediately, no barrier work). + private readonly Continuation _continuation = new(); + + /// Gets or sets the number of Lock invocations per benchmark iteration. + [Params(SmallInvocationCount, LargeInvocationCount)] + public int InvocationCount { get; set; } + + /// Primes the continuation so subsequent Lock / LockValueTask calls hit the fast path. + [GlobalSetup] + public void Setup() => _ = _continuation.Lock(0, observer: null); + + /// Drives Lock calls on the already-locked continuation. + /// A task that completes when every call has been awaited. + [Benchmark] + public async Task Lock_AlreadyLockedFastPath() + { + for (var i = 0; i < InvocationCount; i++) + { + await _continuation.Lock(i, observer: null).ConfigureAwait(false); + } + } + + /// Drives LockValueTask calls on the already-locked continuation. + /// A task that completes when every call has been awaited. + [Benchmark] + public async Task LockValueTask_AlreadyLockedFastPath() + { + for (var i = 0; i < InvocationCount; i++) + { + await _continuation.LockValueTask(i, observer: null).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Releases the continuation. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _continuation.Dispose(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CurrentValueSubjectBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CurrentValueSubjectBenchmarks.cs index 87e7960..6fb657a 100644 --- a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CurrentValueSubjectBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/CurrentValueSubjectBenchmarks.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Extensions.Benchmarks; /// /// Per-emission broadcast cost of with one and four observers, -/// and the subscribe+replay cost. Locks in the single-observer fast path and the immutable-array +/// the subscribe+replay cost, and the ToReadOnlyBehavior factory that wraps a fresh subject as +/// an (observable, observer) pair. Locks in the single-observer fast path and the immutable-array /// snapshot iteration. /// [SimpleJob(RuntimeMoniker.Net10_0)] @@ -116,6 +117,16 @@ public void SubscribeReplayDispose() } } + /// Constructs read-only behavior pairs via ToReadOnlyBehavior. + [Benchmark] + public void ToReadOnlyBehavior_Construct() + { + for (var i = 0; i < EmissionCount; i++) + { + _ = ReactiveExtensions.ToReadOnlyBehavior(Seed); + } + } + /// public void Dispose() { diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/DistinctByAsyncBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/DistinctByAsyncBenchmarks.cs new file mode 100644 index 0000000..2df6401 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/DistinctByAsyncBenchmarks.cs @@ -0,0 +1,120 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission cost of DistinctBy (per-subscription +/// of seen keys) and DistinctUntilChangedBy (one cached previous key). Drives a fully +/// distinct key sequence so the worst-case HashSet-add path is exercised for DistinctBy. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class DistinctByAsyncBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Shared no-op sink. + private readonly BenchmarkNoopObserver _sink = new(); + + /// Source for the DistinctBy pipeline. + private SerialStatelessSubjectAsync _distinctBySource = null!; + + /// Subscription on the DistinctBy pipeline. + private IAsyncDisposable _distinctBySubscription = null!; + + /// Source for the DistinctUntilChangedBy pipeline. + private SerialStatelessSubjectAsync _distinctUntilChangedBySource = null!; + + /// Subscription on the DistinctUntilChangedBy pipeline. + private IAsyncDisposable _distinctUntilChangedBySubscription = null!; + + /// Gets or sets the number of emissions per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires both pipelines. + /// A task that completes when both pipelines are subscribed. + [GlobalSetup] + public async Task SetupAsync() + { + _distinctBySource = new SerialStatelessSubjectAsync(); + _distinctBySubscription = await _distinctBySource + .DistinctBy(static x => x) + .SubscribeAsync(_sink, default).ConfigureAwait(false); + + _distinctUntilChangedBySource = new SerialStatelessSubjectAsync(); + _distinctUntilChangedBySubscription = await _distinctUntilChangedBySource + .DistinctUntilChangedBy(static x => x) + .SubscribeAsync(_sink, default).ConfigureAwait(false); + } + + /// Tears both pipelines down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _distinctBySubscription.DisposeAsync().ConfigureAwait(false); + await _distinctBySource.DisposeAsync().ConfigureAwait(false); + await _distinctUntilChangedBySubscription.DisposeAsync().ConfigureAwait(false); + await _distinctUntilChangedBySource.DisposeAsync().ConfigureAwait(false); + await _sink.DisposeAsync().ConfigureAwait(false); + } + + /// Drives all-distinct values through the DistinctBy pipeline. + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task DistinctBy_AllDistinctKeys() + { + for (var i = 0; i < EmissionCount; i++) + { + await _distinctBySource.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// Drives all-distinct values through the DistinctUntilChangedBy pipeline. + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task DistinctUntilChangedBy_AllDistinctKeys() + { + for (var i = 0; i < EmissionCount; i++) + { + await _distinctUntilChangedBySource.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/DoOnDisposeBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/DoOnDisposeBenchmarks.cs new file mode 100644 index 0000000..bfdb344 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/DoOnDisposeBenchmarks.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the steady-state per-emission cost and the subscribe/dispose churn cost of +/// DoOnDispose, which fires a caller-supplied action when the subscription is disposed. +/// Steady-state numbers reflect pass-through; churn numbers reflect the per-subscribe + dispose +/// allocations including the disposal callback dispatch. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class DoOnDisposeBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallWorkCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeWorkCount = 10_000; + + /// Static no-op dispose action shared by every subscription. + private static readonly Action NoopDisposeAction = static () => { }; + + /// Source for the steady-state benchmark. + private readonly Subject _steadySource = new(); + + /// Source for the subscribe-churn benchmark. + private readonly Subject _churnSource = new(); + + /// No-op sink for both pipelines. + private readonly NoopObserver _sink = new(); + + /// Pre-built DoOnDispose observable reused across churn iterations. + private IObservable _churnPipeline = null!; + + /// Subscription on the steady-state DoOnDispose pipeline. + private IDisposable _steadySubscription = null!; + + /// Gets or sets the number of emissions / churn cycles per benchmark invocation. + [Params(SmallWorkCount, LargeWorkCount)] + public int WorkCount { get; set; } + + /// Wires both pipelines. + [GlobalSetup] + public void Setup() + { + _steadySubscription = _steadySource.DoOnDispose(NoopDisposeAction).Subscribe(_sink); + _churnPipeline = _churnSource.DoOnDispose(NoopDisposeAction); + } + + /// Tears both pipelines down. + [GlobalCleanup] + public void Cleanup() + { + _steadySubscription.Dispose(); + _steadySource.Dispose(); + _churnSource.Dispose(); + } + + /// Drives emissions through the long-lived DoOnDispose pipeline. + [Benchmark] + public void DoOnDispose_SteadyState() + { + for (var i = 0; i < WorkCount; i++) + { + _steadySource.OnNext(i); + } + } + + /// Subscribes and immediately disposes the DoOnDispose pipeline times. + [Benchmark] + public void DoOnDispose_SubscribeAndDispose() + { + for (var i = 0; i < WorkCount; i++) + { + using var subscription = _churnPipeline.Subscribe(_sink); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op observer used as the terminal sink. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/FactoryObservableBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/FactoryObservableBenchmarks.cs index cb0b429..22ccd81 100644 --- a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/FactoryObservableBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/FactoryObservableBenchmarks.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; +using System.Reactive; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; using ReactiveUI.Extensions.Async; @@ -11,9 +12,10 @@ namespace ReactiveUI.Extensions.Benchmarks; /// /// Measures the construct-subscribe-drain cost of the small factory observables: Return, -/// Empty, Throw, Defer, FromAsync, and ToObservableAsync over -/// an . Each benchmark builds the observable per invocation (these -/// factories are not typically cached) so the measurement reflects the cold subscribe path. +/// Empty, Throw, Defer, FromAsync, Start, and +/// ToObservableAsync over an . Each benchmark builds the +/// observable per invocation (these factories are not typically cached) so the measurement +/// reflects the cold subscribe path. /// [SimpleJob(RuntimeMoniker.Net10_0)] [MemoryDiagnoser] @@ -36,6 +38,9 @@ public class FactoryObservableBenchmarks /// Sentinel value emitted by the FromAsync benchmark's synchronously-completing factory. private const int FromAsyncValue = 99; + /// Sentinel value returned by the Start(Func) benchmark's value factory. + private const int StartValue = 13; + /// Cached enumerable so the ToObservableAsync benchmark doesn't measure list allocation. private static readonly int[] EnumerableSource = [.. Enumerable.Range(0, EnumerableLength)]; @@ -61,6 +66,37 @@ public ValueTask Defer_ReturningReturn() => public ValueTask FromAsync_SyncFactory() => ObservableAsync.FromAsync(static _ => new ValueTask(FromAsyncValue)).FirstAsync(); + /// Builds Throw from a fresh error and drains it, swallowing the propagated error + /// so the measurement reflects the construct-subscribe-propagate path. A new exception is built + /// per invocation on purpose — reusing one instance would let + /// append to its stack-trace string on every rethrow, inflating each successive op. + /// A representing the asynchronous drain. + [Benchmark] + public async ValueTask Throw_Drain() + { + try + { + await ObservableAsync.Throw(new InvalidOperationException("benchmark")).FirstAsync().ConfigureAwait(false); + } + catch (InvalidOperationException) + { + // Expected: Throw propagates the error through the drain. Swallowed so the benchmark + // measures the propagation path rather than failing the run. + } + } + + /// Builds Start(Action), schedules the no-op action, and drains the single . + /// The single emission. + [Benchmark] + public ValueTask StartAction_Drain() => + ObservableAsync.Start(static () => { }).FirstAsync(); + + /// Builds Start(Func), schedules the value factory, and drains its single emission. + /// The factory's emitted value. + [Benchmark] + public ValueTask StartFunc_Drain() => + ObservableAsync.Start(static () => StartValue).FirstAsync(); + /// Builds ToObservableAsync from a 100-element enumerable and counts the emissions. /// The element count, expected to be 100. [Benchmark] diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/GetMinMaxAndBooleanCombineBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/GetMinMaxAndBooleanCombineBenchmarks.cs index 27c49d2..e0cf4a9 100644 --- a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/GetMinMaxAndBooleanCombineBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/GetMinMaxAndBooleanCombineBenchmarks.cs @@ -11,9 +11,10 @@ namespace ReactiveUI.Extensions.Benchmarks; /// -/// Measures the per-emission cost of GetMax and CombineLatestValuesAreAllTrue — -/// parity-helper combinators built on top of CombineLatest. Each pipeline has four -/// pre-primed sources so every emission produces a downstream value. +/// Measures the per-emission cost of GetMax, GetMin, and +/// CombineLatestValuesAreAllTrue — parity-helper combinators built on top of +/// CombineLatest. Each pipeline has four pre-primed sources so every emission produces a +/// downstream value. /// [SimpleJob(RuntimeMoniker.Net10_0)] [MemoryDiagnoser] @@ -41,6 +42,12 @@ public class GetMinMaxAndBooleanCombineBenchmarks : IDisposable /// Subscription on the GetMax pipeline. private IAsyncDisposable _maxSubscription = null!; + /// Sources for the GetMin pipeline. + private SerialStatelessSubjectAsync[] _minSources = null!; + + /// Subscription on the GetMin pipeline. + private IAsyncDisposable _minSubscription = null!; + /// Sources for the CombineLatestValuesAreAllTrue pipeline. private SerialStatelessSubjectAsync[] _allTrueSources = null!; @@ -76,6 +83,26 @@ public async Task SetupAsync() await _maxSources[i].OnNextAsync(0, default).ConfigureAwait(false); } + _minSources = new SerialStatelessSubjectAsync[SourceCount]; + for (var i = 0; i < SourceCount; i++) + { + _minSources[i] = new SerialStatelessSubjectAsync(); + } + + var minOthers = new IObservableAsync[SourceCount - 1]; + for (var i = 1; i < SourceCount; i++) + { + minOthers[i - 1] = _minSources[i]; + } + + _minSubscription = await _minSources[0].GetMin(minOthers) + .SubscribeAsync(_intSink, default).ConfigureAwait(false); + + for (var i = 0; i < SourceCount; i++) + { + await _minSources[i].OnNextAsync(0, default).ConfigureAwait(false); + } + _allTrueSources = new SerialStatelessSubjectAsync[SourceCount]; var allTrueSnapshot = new IObservableAsync[SourceCount]; for (var i = 0; i < SourceCount; i++) @@ -104,6 +131,12 @@ public async Task CleanupAsync() await _maxSources[i].DisposeAsync().ConfigureAwait(false); } + await _minSubscription.DisposeAsync().ConfigureAwait(false); + for (var i = 0; i < _minSources.Length; i++) + { + await _minSources[i].DisposeAsync().ConfigureAwait(false); + } + await _allTrueSubscription.DisposeAsync().ConfigureAwait(false); for (var i = 0; i < _allTrueSources.Length; i++) { @@ -126,6 +159,18 @@ public async Task GetMax_FourPrimedSources() } } + /// Pushes values into the first source of the GetMin pipeline; every emission produces a downstream value because all four are primed. + /// A task that completes when every value has been propagated. + [Benchmark] + public async Task GetMin_FourPrimedSources() + { + var src0 = _minSources[0]; + for (var i = 0; i < EmissionCount; i++) + { + await src0.OnNextAsync(i, default).ConfigureAwait(false); + } + } + /// Pushes true values into the first source of the CombineLatestValuesAreAllTrue pipeline. /// A task that completes when every value has been propagated. [Benchmark] diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/MiscSyncOperatorBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/MiscSyncOperatorBenchmarks.cs new file mode 100644 index 0000000..1de6a58 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/MiscSyncOperatorBenchmarks.cs @@ -0,0 +1,219 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-emission steady-state cost for a cluster of remaining sync helpers that hadn't yet been +/// benchmarked: SwitchIfEmpty, SampleLatest, ReplayLastOnSubscribe, +/// ObserveOnIf (immediate scheduler — measures the conditional-bypass overhead), +/// FromArray, and RunAll. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class MiscSyncOperatorBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Initial value used by the ReplayLastOnSubscribe pipeline. + private const int InitialValue = -1; + + /// Sampler-trigger value reused by every SampleLatest tick. + private static readonly object _samplerSentinel = new(); + + /// Pre-built array source for the FromArray benchmark. + private static readonly int[] _arrayPayload = [0, 1, 2, 3, 4, 5, 6, 7]; + + /// Pre-built one-shot Unit observables for the RunAll benchmark. + private static readonly IReadOnlyList> _runAllSources = + [ + Observables.Return(Unit.Default), + Observables.Return(Unit.Default), + Observables.Return(Unit.Default), + Observables.Return(Unit.Default), + ]; + + /// Source for the SwitchIfEmpty pipeline. + private readonly Subject _switchIfEmptySource = new(); + + /// Source for the SampleLatest pipeline. + private readonly Subject _sampleLatestSource = new(); + + /// Trigger for the SampleLatest pipeline. + private readonly Subject _sampleLatestTrigger = new(); + + /// Source for the ReplayLastOnSubscribe pipeline. + private readonly Subject _replayLastSource = new(); + + /// Source for the ObserveOnIf pipeline. + private readonly Subject _observeOnIfSource = new(); + + /// Reused sink. + private readonly NoopObserver _intSink = new(); + + /// Reused unit sink. + private readonly NoopObserver _unitSink = new(); + + /// Subscription on the SwitchIfEmpty pipeline. + private IDisposable _switchIfEmptySubscription = null!; + + /// Subscription on the SampleLatest pipeline. + private IDisposable _sampleLatestSubscription = null!; + + /// Subscription on the ReplayLastOnSubscribe pipeline. + private IDisposable _replayLastSubscription = null!; + + /// Subscription on the ObserveOnIf pipeline. + private IDisposable _observeOnIfSubscription = null!; + + /// Gets or sets the number of emissions pushed per invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires every pipeline. + [GlobalSetup] + public void Setup() + { + _switchIfEmptySubscription = _switchIfEmptySource + .SwitchIfEmpty(Observable.Empty()) + .Subscribe(_intSink); + _sampleLatestSubscription = _sampleLatestSource + .SampleLatest(_sampleLatestTrigger) + .Subscribe(_intSink); + _replayLastSubscription = _replayLastSource + .ReplayLastOnSubscribe(InitialValue) + .Subscribe(_intSink); + _observeOnIfSubscription = _observeOnIfSource + .ObserveOnIf(false, Scheduler.Immediate) + .Subscribe(_intSink); + } + + /// Tears every pipeline down. + [GlobalCleanup] + public void Cleanup() + { + _switchIfEmptySubscription.Dispose(); + _sampleLatestSubscription.Dispose(); + _replayLastSubscription.Dispose(); + _observeOnIfSubscription.Dispose(); + _switchIfEmptySource.Dispose(); + _sampleLatestSource.Dispose(); + _sampleLatestTrigger.Dispose(); + _replayLastSource.Dispose(); + _observeOnIfSource.Dispose(); + } + + /// Drives values through the SwitchIfEmpty pipeline (no empty fallback fires). + [Benchmark] + public void SwitchIfEmpty_HappyPath() + { + for (var i = 0; i < EmissionCount; i++) + { + _switchIfEmptySource.OnNext(i); + } + } + + /// Drives source+trigger pairs through the SampleLatest pipeline. + [Benchmark] + public void SampleLatest_AlternatingTriggers() + { + for (var i = 0; i < EmissionCount; i++) + { + _sampleLatestSource.OnNext(i); + _sampleLatestTrigger.OnNext(_samplerSentinel); + } + } + + /// Drives values through the ReplayLastOnSubscribe steady-state path. + [Benchmark] + public void ReplayLastOnSubscribe_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _replayLastSource.OnNext(i); + } + } + + /// Drives values through the bypass branch of ObserveOnIf. + [Benchmark] + public void ObserveOnIf_FalseBypass() + { + for (var i = 0; i < EmissionCount; i++) + { + _observeOnIfSource.OnNext(i); + } + } + + /// Per-invocation FromArray subscribe + drain over a small array. + [Benchmark] + public void FromArray_DrainAndDispose() + { + for (var i = 0; i < EmissionCount; i++) + { + using var sub = _arrayPayload.FromArray().Subscribe(_intSink); + } + } + + /// Per-invocation RunAll subscribe + drain over a static 4-source list. + [Benchmark] + public void RunAll_FourReturnSources() + { + for (var i = 0; i < EmissionCount; i++) + { + using var sub = _runAllSources.RunAll().Subscribe(_unitSink); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/MulticastBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/MulticastBenchmarks.cs index 16b49d9..62d0f69 100644 --- a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/MulticastBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/MulticastBenchmarks.cs @@ -11,9 +11,10 @@ namespace ReactiveUI.Extensions.Benchmarks; /// -/// Measures the per-emission cost of Publish + RefCount with four shared observers -/// attached. Exercises the multicast / connectable wiring (subject snapshot, single upstream -/// subscribe, fan-out to N observers per emission). +/// Measures the per-emission cost of Publish + RefCount and the raw +/// Multicast(subject) + Connect primitive, each with four shared observers attached. +/// Exercises the multicast / connectable wiring (subject snapshot, single upstream subscribe, +/// fan-out to N observers per emission). /// [SimpleJob(RuntimeMoniker.Net10_0)] [MemoryDiagnoser] @@ -38,6 +39,15 @@ public class MulticastBenchmarks : IDisposable /// Subscriptions returned by each attached observer. private List _subscriptions = null!; + /// Upstream subject feeding the raw Multicast(subject) pipeline. + private SerialStatelessSubjectAsync _multicastSource = null!; + + /// Subscriptions on the raw Multicast(subject) pipeline. + private List _multicastSubscriptions = null!; + + /// Connection handle returned by ConnectAsync on the raw multicast pipeline. + private IAsyncDisposable _multicastConnection = null!; + /// Gets or sets the number of emissions pushed through the multicast per benchmark invocation. [Params(SmallEmissionCount, LargeEmissionCount)] public int EmissionCount { get; set; } @@ -54,6 +64,16 @@ public async Task SetupAsync() { _subscriptions.Add(await shared.SubscribeAsync(_sink, default).ConfigureAwait(false)); } + + _multicastSource = new SerialStatelessSubjectAsync(); + var connectable = _multicastSource.Multicast(SubjectAsync.Create()); + _multicastSubscriptions = new List(ObserverCount); + for (var i = 0; i < ObserverCount; i++) + { + _multicastSubscriptions.Add(await connectable.SubscribeAsync(_sink, default).ConfigureAwait(false)); + } + + _multicastConnection = await connectable.ConnectAsync(default).ConfigureAwait(false); } /// Tears every subscription and the source subject down. @@ -67,6 +87,14 @@ public async Task CleanupAsync() } await _source.DisposeAsync().ConfigureAwait(false); + + await _multicastConnection.DisposeAsync().ConfigureAwait(false); + for (var i = 0; i < _multicastSubscriptions.Count; i++) + { + await _multicastSubscriptions[i].DisposeAsync().ConfigureAwait(false); + } + + await _multicastSource.DisposeAsync().ConfigureAwait(false); await _sink.DisposeAsync().ConfigureAwait(false); } @@ -81,6 +109,17 @@ public async Task PublishRefCount_FourSharedObservers() } } + /// Pushes values through the raw Multicast(subject) + Connect pipeline. + /// A task that completes when every observer has been notified for every emission. + [Benchmark] + public async Task MulticastConnect_FourSharedObservers() + { + for (var i = 0; i < EmissionCount; i++) + { + await _multicastSource.OnNextAsync(i, default).ConfigureAwait(false); + } + } + /// public void Dispose() { diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ObservableSubscriptionBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ObservableSubscriptionBenchmarks.cs new file mode 100644 index 0000000..79be2cb --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ObservableSubscriptionBenchmarks.cs @@ -0,0 +1,146 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the cost of the synchronous subscription helpers in +/// : SubscribeGetValue, +/// SubscribeGetError, SubscribeAndComplete, WaitForValue, +/// WaitForCompletion, and WaitForError. Each call subscribes a one-shot blocking +/// observer; the source is a synchronously-completing single-value observable so the helpers do +/// not wait on a real event. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ObservableSubscriptionBenchmarks +{ + /// Low end of the parameter sweep. + private const int SmallInvocationCount = 100; + + /// High end of the parameter sweep. + private const int LargeInvocationCount = 1_000; + + /// Constant value emitted by the synchronous source. + private const int SampledValue = 42; + + /// Pre-built synchronously-terminating int source. + private readonly IObservable _intSource = new InlineCompletingObservable(SampledValue); + + /// Pre-built synchronously-terminating unit source. + private readonly IObservable _unitSource = new InlineCompletingObservable(Unit.Default); + + /// Pre-built synchronously-erroring source for . + private readonly IObservable _erroringSource = new InlineErroringObservable(); + + /// Gets or sets the number of invocations per benchmark iteration. + [Params(SmallInvocationCount, LargeInvocationCount)] + public int InvocationCount { get; set; } + + /// Measures the cost of . + [Benchmark] + public void SubscribeGetValue_SingleValueSource() + { + for (var i = 0; i < InvocationCount; i++) + { + _ = _intSource.SubscribeGetValue(); + } + } + + /// Measures the cost of on a sync-erroring source. + [Benchmark] + public void SubscribeGetError_ErroringSource() + { + for (var i = 0; i < InvocationCount; i++) + { + _ = _erroringSource.SubscribeGetError(); + } + } + + /// Measures the cost of . + [Benchmark] + public void SubscribeAndComplete_UnitSource() + { + for (var i = 0; i < InvocationCount; i++) + { + _unitSource.SubscribeAndComplete(); + } + } + + /// Measures the cost of on a sync-completing source. + [Benchmark] + public void WaitForValue_SyncSource() + { + for (var i = 0; i < InvocationCount; i++) + { + _ = _intSource.WaitForValue(); + } + } + + /// Measures the cost of on a sync-completing source. + [Benchmark] + public void WaitForCompletion_SyncSource() + { + for (var i = 0; i < InvocationCount; i++) + { + _unitSource.WaitForCompletion(); + } + } + + /// Measures the cost of on a sync-erroring source. + [Benchmark] + public void WaitForError_OnError() + { + for (var i = 0; i < InvocationCount; i++) + { + _ = _erroringSource.WaitForError(); + } + } + + /// Synchronously emits the configured value and completes inside the subscribe call. + /// The element type. + /// The value emitted on every subscribe. + private sealed class InlineCompletingObservable(T value) : IObservable + { + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnNext(value); + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + } + + /// Synchronously emits an error inside the subscribe call. + /// The element type. + private sealed class InlineErroringObservable : IObservable + { + /// Shared error instance to avoid per-call allocations. + private static readonly InvalidOperationException SharedError = new("benchmark"); + + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnError(SharedError); + return EmptyDisposable.Instance; + } + } + + /// Singleton no-op disposable. + private sealed class EmptyDisposable : IDisposable + { + /// Singleton instance. + public static readonly EmptyDisposable Instance = new(); + + /// + public void Dispose() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ObserveOnComparisonBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ObserveOnComparisonBenchmarks.cs new file mode 100644 index 0000000..4e9605f --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ObserveOnComparisonBenchmarks.cs @@ -0,0 +1,164 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Head-to-head spike comparing our own ObserveOnSafe (backed by ObserveOnObservable) +/// against System.Reactive's Observable.ObserveOn on the immediate scheduler (where our +/// operator takes a synchronous pass-through fast-path) and the current-thread scheduler (where both +/// run the queue-and-drain marshaller). Sinks are no-ops so the numbers reflect the marshalling +/// machinery, not downstream work. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ObserveOnComparisonBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Shared no-op sink. + private readonly NoopObserver _sink = new(); + + /// Source feeding our immediate-scheduler pipeline. + private readonly Subject _oursImmediateSource = new(); + + /// Source feeding the System.Reactive immediate-scheduler pipeline. + private readonly Subject _rxImmediateSource = new(); + + /// Source feeding our current-thread pipeline. + private readonly Subject _oursCurrentThreadSource = new(); + + /// Source feeding the System.Reactive current-thread pipeline. + private readonly Subject _rxCurrentThreadSource = new(); + + /// Subscription on our immediate-scheduler pipeline. + private IDisposable _oursImmediateSub = null!; + + /// Subscription on the System.Reactive immediate-scheduler pipeline. + private IDisposable _rxImmediateSub = null!; + + /// Subscription on our current-thread pipeline. + private IDisposable _oursCurrentThreadSub = null!; + + /// Subscription on the System.Reactive current-thread pipeline. + private IDisposable _rxCurrentThreadSub = null!; + + /// Gets or sets the number of emissions pushed through each pipeline per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires all four pipelines. + [GlobalSetup] + public void Setup() + { + _oursImmediateSub = _oursImmediateSource.ObserveOnSafe(Scheduler.Immediate).Subscribe(_sink); + _rxImmediateSub = _rxImmediateSource.ObserveOn(Scheduler.Immediate).Subscribe(_sink); + _oursCurrentThreadSub = _oursCurrentThreadSource.ObserveOnSafe(Scheduler.CurrentThread).Subscribe(_sink); + _rxCurrentThreadSub = _rxCurrentThreadSource.ObserveOn(Scheduler.CurrentThread).Subscribe(_sink); + } + + /// Tears all four pipelines down. + [GlobalCleanup] + public void Cleanup() + { + _oursImmediateSub.Dispose(); + _rxImmediateSub.Dispose(); + _oursCurrentThreadSub.Dispose(); + _rxCurrentThreadSub.Dispose(); + _oursImmediateSource.Dispose(); + _rxImmediateSource.Dispose(); + _oursCurrentThreadSource.Dispose(); + _rxCurrentThreadSource.Dispose(); + } + + /// Our ObserveOnSafe on the immediate scheduler (synchronous pass-through fast-path). + [Benchmark(Baseline = true)] + public void Ours_Immediate() + { + for (var i = 0; i < EmissionCount; i++) + { + _oursImmediateSource.OnNext(i); + } + } + + /// System.Reactive ObserveOn on the immediate scheduler. + [Benchmark] + public void Rx_Immediate() + { + for (var i = 0; i < EmissionCount; i++) + { + _rxImmediateSource.OnNext(i); + } + } + + /// Our ObserveOnSafe on the current-thread scheduler (queue + drain). + [Benchmark] + public void Ours_CurrentThread() + { + for (var i = 0; i < EmissionCount; i++) + { + _oursCurrentThreadSource.OnNext(i); + } + } + + /// System.Reactive ObserveOn on the current-thread scheduler. + [Benchmark] + public void Rx_CurrentThread() + { + for (var i = 0; i < EmissionCount; i++) + { + _rxCurrentThreadSource.OnNext(i); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op synchronous observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/RetryAndBufferSubscribeBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/RetryAndBufferSubscribeBenchmarks.cs new file mode 100644 index 0000000..23b1f53 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/RetryAndBufferSubscribeBenchmarks.cs @@ -0,0 +1,212 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Subscribe-and-dispose cost for the remaining scheduler/retry-driven sync operators that don't +/// yield meaningful per-emission measurements without real wall-clock time: the retry family +/// (RetryWithBackoff, RetryWithDelay, RetryWithFixedDelay, +/// RetryForeverWithDelay), the idle-buffer family (BufferUntilIdle, +/// BufferUntilInactive), DebounceImmediate, ThrottleOnScheduler, +/// ThrottleUntilTrue, DetectStale, and the SyncTimer factory. Measures the +/// per-subscription wrapper allocation; the source never errors / emits during the bench so no +/// retry or timer work fires. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class RetryAndBufferSubscribeBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallInvocationCount = 100; + + /// High end of the parameter sweep. + private const int LargeInvocationCount = 1_000; + + /// Retry cap for the retry-family benchmarks. + private const int RetryCount = 3; + + /// Long-enough window so no scheduled work fires during the subscribe-and-dispose lifetime. + private static readonly TimeSpan _longWindow = TimeSpan.FromSeconds(60); + + /// Static fixed-delay selector for RetryWithDelay; avoids per-call capture. + private static readonly Func _delaySelector = static _ => _longWindow; + + /// Static always-false predicate for ThrottleUntilTrue. + private static readonly Func _falsePredicate = static _ => false; + + /// Source feeding every pipeline. + private readonly Subject _source = new(); + + /// No-op int sink. + private readonly NoopObserver _intSink = new(); + + /// No-op list sink for the buffering operators. + private readonly NoopObserver> _listSink = new(); + + /// No-op stale sink for DetectStale. + private readonly NoopObserver> _staleSink = new(); + + /// No-op DateTime sink for SyncTimer. + private readonly NoopObserver _dateSink = new(); + + /// Gets or sets the number of subscribe-and-dispose cycles per benchmark invocation. + [Params(SmallInvocationCount, LargeInvocationCount)] + public int InvocationCount { get; set; } + + /// Loops RetryWithBackoff subscribe → dispose (source never errors). + [Benchmark] + public void RetryWithBackoff_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.RetryWithBackoff(RetryCount, _longWindow).Subscribe(_intSink); + } + } + + /// Loops RetryWithDelay subscribe → dispose. + [Benchmark] + public void RetryWithDelay_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.RetryWithDelay(RetryCount, _delaySelector).Subscribe(_intSink); + } + } + + /// Loops RetryWithFixedDelay subscribe → dispose. + [Benchmark] + public void RetryWithFixedDelay_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.RetryWithFixedDelay(RetryCount, _longWindow).Subscribe(_intSink); + } + } + + /// Loops RetryForeverWithDelay subscribe → dispose. + [Benchmark] + public void RetryForeverWithDelay_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.RetryForeverWithDelay(_longWindow).Subscribe(_intSink); + } + } + + /// Loops BufferUntilIdle subscribe → dispose. + [Benchmark] + public void BufferUntilIdle_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.BufferUntilIdle(_longWindow, Scheduler.Default).Subscribe(_listSink); + } + } + + /// Loops BufferUntilInactive subscribe → dispose. + [Benchmark] + public void BufferUntilInactive_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.BufferUntilInactive(_longWindow, Scheduler.Default).Subscribe(_listSink); + } + } + + /// Loops DebounceImmediate subscribe → dispose. + [Benchmark] + public void DebounceImmediate_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.DebounceImmediate(_longWindow, Scheduler.Default).Subscribe(_intSink); + } + } + + /// Loops ThrottleOnScheduler subscribe → dispose. + [Benchmark] + public void ThrottleOnScheduler_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.ThrottleOnScheduler(_longWindow, Scheduler.Default).Subscribe(_intSink); + } + } + + /// Loops ThrottleUntilTrue subscribe → dispose. + [Benchmark] + public void ThrottleUntilTrue_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.ThrottleUntilTrue(_longWindow, _falsePredicate).Subscribe(_intSink); + } + } + + /// Loops DetectStale subscribe → dispose. + [Benchmark] + public void DetectStale_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.DetectStale(_longWindow, Scheduler.Default).Subscribe(_staleSink); + } + } + + /// Loops SyncTimer subscribe → dispose (timer never fires within the cycle). + [Benchmark] + public void SyncTimer_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = ReactiveExtensions.SyncTimer(_longWindow, Scheduler.Default).Subscribe(_dateSink); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _source.Dispose(); + } + + /// No-op observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ScheduledSourceAndObserveOnSafeBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ScheduledSourceAndObserveOnSafeBenchmarks.cs new file mode 100644 index 0000000..83c6d71 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ScheduledSourceAndObserveOnSafeBenchmarks.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Concurrency; +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission cost of the sync source-scheduling operators: +/// Schedule(source, dueTime, scheduler) and ObserveOnSafe(scheduler). Both run on +/// with a zero delay so each scheduled action executes inline, +/// isolating the operator's per-emission overhead from real timer latency. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ScheduledSourceAndObserveOnSafeBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Shared no-op sink. + private readonly NoopObserver _sink = new(); + + /// Source feeding the Schedule(source) pipeline. + private readonly Subject _scheduleSource = new(); + + /// Source feeding the ObserveOnSafe pipeline. + private readonly Subject _observeOnSafeSource = new(); + + /// Subscription on the Schedule(source) pipeline. + private IDisposable _scheduleSubscription = null!; + + /// Subscription on the ObserveOnSafe pipeline. + private IDisposable _observeOnSafeSubscription = null!; + + /// Gets or sets the number of emissions pushed through each pipeline per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires both pipelines against the immediate scheduler. + [GlobalSetup] + public void Setup() + { + _scheduleSubscription = ((IObservable)_scheduleSource).Schedule(TimeSpan.Zero, Scheduler.Immediate).Subscribe(_sink); + _observeOnSafeSubscription = _observeOnSafeSource.ObserveOnSafe(Scheduler.Immediate).Subscribe(_sink); + } + + /// Tears both pipelines down. + [GlobalCleanup] + public void Cleanup() + { + _scheduleSubscription.Dispose(); + _observeOnSafeSubscription.Dispose(); + _scheduleSource.Dispose(); + _observeOnSafeSource.Dispose(); + } + + /// Drives values through Schedule(source, TimeSpan.Zero, Immediate). + [Benchmark] + public void ScheduleSource_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _scheduleSource.OnNext(i); + } + } + + /// Drives values through ObserveOnSafe(Immediate). + [Benchmark] + public void ObserveOnSafe_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _observeOnSafeSource.OnNext(i); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op synchronous observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ScheduledValueBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ScheduledValueBenchmarks.cs new file mode 100644 index 0000000..a84e138 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ScheduledValueBenchmarks.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Concurrency; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the construct-subscribe-emit cost of the single-value scheduling operators +/// (Schedule(value, ...) backed by ScheduledValueObservable) and the +/// ScheduleSafe helper. Every variant runs on with a zero +/// or already-elapsed due time so the scheduled work executes inline during subscribe, capturing the +/// operator's construction and dispatch overhead rather than timer latency. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +[SuppressMessage( + "Performance", + "CA1822:Mark members as static", + Justification = "BenchmarkDotNet drives benchmarks through an instance; the methods cannot be static.")] +public class ScheduledValueBenchmarks +{ + /// Sentinel value scheduled by every benchmark. + private const int Value = 42; + + /// Already-elapsed absolute due time so the absolute-schedule variant fires immediately. + private static readonly DateTimeOffset ElapsedDueTime = DateTimeOffset.MinValue; + + /// Shared no-op sink for the scheduled-value pipelines. + private static readonly NoopObserver Sink = new(); + + /// Schedules a single value with a zero delay and drains it. + [Benchmark] + public void ScheduleValue_TimeSpan() => + Value.Schedule(TimeSpan.Zero, Scheduler.Immediate).Subscribe(Sink).Dispose(); + + /// Schedules a single value at an already-elapsed absolute time and drains it. + [Benchmark] + public void ScheduleValue_DateTimeOffset() => + Value.Schedule(ElapsedDueTime, Scheduler.Immediate).Subscribe(Sink).Dispose(); + + /// Schedules a single value with a transform function (no delay) and drains it. + [Benchmark] + public void ScheduleValue_Transform() => + Value.Schedule(Scheduler.Immediate, static x => x + 1).Subscribe(Sink).Dispose(); + + /// Schedules a single value with an inspection action and drains it. + [Benchmark] + public void ScheduleValue_Action() => + Value.Schedule(TimeSpan.Zero, Scheduler.Immediate, static _ => { }).Subscribe(Sink).Dispose(); + + /// Schedules a no-op action immediately via ScheduleSafe. + [Benchmark] + public void ScheduleSafe_Immediate() => + Scheduler.Immediate.ScheduleSafe(static () => { }).Dispose(); + + /// Schedules a no-op action with a zero delay via ScheduleSafe. + [Benchmark] + public void ScheduleSafe_Delayed() => + Scheduler.Immediate.ScheduleSafe(TimeSpan.Zero, static () => { }).Dispose(); + + /// No-op synchronous observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SequentialMatchAndConcurrencyBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SequentialMatchAndConcurrencyBenchmarks.cs new file mode 100644 index 0000000..382b16d --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SequentialMatchAndConcurrencyBenchmarks.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-cycle cost of the two sequential-state-machine sync operators that hadn't been benchmarked: +/// (walks an +/// ordered candidate list and emits the first match) and +/// (caps concurrent task execution). +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class SequentialMatchAndConcurrencyBenchmarks +{ + /// Low end of the parameter sweep. + private const int SmallInvocationCount = 100; + + /// High end of the parameter sweep. + private const int LargeInvocationCount = 1_000; + + /// Fallback result emitted if no candidate matches. + private const int Fallback = -1; + + /// Concurrency cap for the WithLimitedConcurrency bench. + private const int MaxConcurrency = 4; + + /// Size of the cached task set fed into the concurrency-limited bench. + private const int TaskPoolSize = 8; + + /// Candidate keys walked by the FirstMatchFromCandidates bench. + private static readonly int[] _candidates = [1, 2, 3, 4]; + + /// Static projection from key to a single-value observable; matches on the first key. + private static readonly Func> _project = static key => Observables.Return(key); + + /// Static transform applied to each raw value. + private static readonly Func _transform = static raw => raw; + + /// Static predicate that matches the first candidate, so the operator terminates eagerly. + private static readonly Func _predicate = static value => value == 1; + + /// Reusable no-op sink so allocation tracking attributes only to the operator paths. + private readonly NoopObserver _sink = new(); + + /// Pre-completed tasks pumped into the WithLimitedConcurrency bench. + private Task[] _completedTasks = null!; + + /// Gets or sets the number of invocations per benchmark iteration. + [Params(SmallInvocationCount, LargeInvocationCount)] + public int InvocationCount { get; set; } + + /// Builds the cached task set used by the concurrency-limited bench. + [GlobalSetup] + public void Setup() + { + _completedTasks = new Task[TaskPoolSize]; + for (var i = 0; i < _completedTasks.Length; i++) + { + _completedTasks[i] = Task.FromResult(i); + } + } + + /// Loops FirstMatchFromCandidates subscribe-and-drain cycles (matches on first candidate). + [Benchmark] + public void FirstMatchFromCandidates_FirstHit() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _candidates.FirstMatchFromCandidates(_project, _transform, _predicate, Fallback) + .Subscribe(_sink); + } + } + + /// Loops WithLimitedConcurrency subscribe cycles over a pre-built task set. + [Benchmark] + public void WithLimitedConcurrency_PreCompletedTasks() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _completedTasks.WithLimitedConcurrency(MaxConcurrency).Subscribe(_sink); + } + } + + /// No-op observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ShuffleBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ShuffleBenchmarks.cs new file mode 100644 index 0000000..d5b0ed5 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ShuffleBenchmarks.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission cost of Shuffle, which randomises an emitted array in-place +/// or via a fresh copy on each emission. Drives a constant-length array source so the steady-state +/// numbers reflect the operator's shuffle work, not array construction. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ShuffleBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Length of the array emitted per cycle. + private const int ArrayLength = 16; + + /// Source feeding the Shuffle pipeline. + private readonly Subject _source = new(); + + /// No-op sink absorbing the shuffled arrays. + private readonly NoopObserver _sink = new(); + + /// Pre-built array emitted once per cycle. + private int[] _payload = null!; + + /// Subscription on the Shuffle pipeline. + private IDisposable _subscription = null!; + + /// Gets or sets the number of emissions pushed through the pipeline per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires the Shuffle pipeline. + [GlobalSetup] + public void Setup() + { + _payload = new int[ArrayLength]; + for (var i = 0; i < _payload.Length; i++) + { + _payload[i] = i; + } + + _subscription = _source.Shuffle().Subscribe(_sink); + } + + /// Tears the pipeline down. + [GlobalCleanup] + public void Cleanup() + { + _subscription.Dispose(); + _source.Dispose(); + } + + /// Drives array emissions through the Shuffle pipeline. + [Benchmark] + public void Shuffle_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _source.OnNext(_payload); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// No-op observer used as the terminal sink. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/StatelessReplayLatestPublishBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/StatelessReplayLatestPublishBenchmarks.cs new file mode 100644 index 0000000..17cfb0a --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/StatelessReplayLatestPublishBenchmarks.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission cost of StatelessReplayLatestPublish (multicast variant that +/// replays the most recent value to late subscribers, with no replay buffer beyond one slot). +/// Two observers are connected so the broadcast loop is exercised. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class StatelessReplayLatestPublishBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// First downstream sink. + private readonly BenchmarkNoopObserver _sinkA = new(); + + /// Second downstream sink. + private readonly BenchmarkNoopObserver _sinkB = new(); + + /// Source feeding the multicast. + private SerialStatelessSubjectAsync _source = null!; + + /// Connect-disposable from ConnectAsync. + private IAsyncDisposable _connect = null!; + + /// First subscription on the published observable. + private IAsyncDisposable _subA = null!; + + /// Second subscription on the published observable. + private IAsyncDisposable _subB = null!; + + /// Gets or sets the number of emissions pushed per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires the multicast with two observers attached and the source connected. + /// A task that completes when setup is done. + [GlobalSetup] + public async Task SetupAsync() + { + _source = new SerialStatelessSubjectAsync(); + var connectable = _source.StatelessReplayLatestPublish(); + + _subA = await connectable.SubscribeAsync(_sinkA, default).ConfigureAwait(false); + _subB = await connectable.SubscribeAsync(_sinkB, default).ConfigureAwait(false); + _connect = await connectable.ConnectAsync(default).ConfigureAwait(false); + } + + /// Tears the multicast down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _subA.DisposeAsync().ConfigureAwait(false); + await _subB.DisposeAsync().ConfigureAwait(false); + await _connect.DisposeAsync().ConfigureAwait(false); + await _source.DisposeAsync().ConfigureAwait(false); + await _sinkA.DisposeAsync().ConfigureAwait(false); + await _sinkB.DisposeAsync().ConfigureAwait(false); + } + + /// Drives values through the published multicast. + /// A task that completes when every value has been broadcast. + [Benchmark] + public async Task StatelessReplayLatestPublish_TwoObservers() + { + for (var i = 0; i < EmissionCount; i++) + { + await _source.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/StatelessReplayLatestSubjectBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/StatelessReplayLatestSubjectBenchmarks.cs new file mode 100644 index 0000000..776d50b --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/StatelessReplayLatestSubjectBenchmarks.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using ReactiveUI.Extensions.Async; +using ReactiveUI.Extensions.Async.Subjects; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-emission broadcast cost of the two stateless replay-latest subjects that hadn't been +/// benchmarked: and +/// . Each runs with two observers attached +/// so the fan-out path is exercised. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class StatelessReplayLatestSubjectBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// First sink shared across both pipelines. + private readonly BenchmarkNoopObserver _sinkA = new(); + + /// Second sink shared across both pipelines. + private readonly BenchmarkNoopObserver _sinkB = new(); + + /// Concurrent stateless replay subject under test. + private ConcurrentStatelessReplayLatestSubjectAsync _concurrentSubject = null!; + + /// Serial stateless replay subject under test. + private SerialStatelessReplayLastSubjectAsync _serialSubject = null!; + + /// Concurrent subject's first subscription. + private IAsyncDisposable _concurrentSubA = null!; + + /// Concurrent subject's second subscription. + private IAsyncDisposable _concurrentSubB = null!; + + /// Serial subject's first subscription. + private IAsyncDisposable _serialSubA = null!; + + /// Serial subject's second subscription. + private IAsyncDisposable _serialSubB = null!; + + /// Gets or sets the number of emissions pushed per invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires both subjects with two observers each. + /// A task that completes when setup is done. + [GlobalSetup] + public async Task SetupAsync() + { + _concurrentSubject = new ConcurrentStatelessReplayLatestSubjectAsync(Optional.Empty); + _concurrentSubA = await _concurrentSubject.SubscribeAsync(_sinkA, default).ConfigureAwait(false); + _concurrentSubB = await _concurrentSubject.SubscribeAsync(_sinkB, default).ConfigureAwait(false); + + _serialSubject = new SerialStatelessReplayLastSubjectAsync(Optional.Empty); + _serialSubA = await _serialSubject.SubscribeAsync(_sinkA, default).ConfigureAwait(false); + _serialSubB = await _serialSubject.SubscribeAsync(_sinkB, default).ConfigureAwait(false); + } + + /// Tears every subject and subscription down. + /// A task that completes when teardown is done. + [GlobalCleanup] + public async Task CleanupAsync() + { + await _concurrentSubA.DisposeAsync().ConfigureAwait(false); + await _concurrentSubB.DisposeAsync().ConfigureAwait(false); + await _serialSubA.DisposeAsync().ConfigureAwait(false); + await _serialSubB.DisposeAsync().ConfigureAwait(false); + await _concurrentSubject.DisposeAsync().ConfigureAwait(false); + await _serialSubject.DisposeAsync().ConfigureAwait(false); + await _sinkA.DisposeAsync().ConfigureAwait(false); + await _sinkB.DisposeAsync().ConfigureAwait(false); + } + + /// Drives values through the concurrent broadcast path. + /// A task that completes when every value has been broadcast. + [Benchmark] + public async Task ConcurrentStatelessReplayLatest_TwoObservers() + { + for (var i = 0; i < EmissionCount; i++) + { + await _concurrentSubject.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// Drives values through the serial broadcast path. + /// A task that completes when every value has been broadcast. + [Benchmark] + public async Task SerialStatelessReplayLast_TwoObservers() + { + for (var i = 0; i < EmissionCount; i++) + { + await _serialSubject.OnNextAsync(i, default).ConfigureAwait(false); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains async teardown so can return synchronously. + /// true when called from . + [SuppressMessage( + "Major Bug", + "S4462:Calls to async methods should not be blocking", + Justification = "IDisposable.Dispose is synchronous by contract; benchmark teardown must wait for async cleanup before returning.")] + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + CleanupAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SubscribeSynchronousBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SubscribeSynchronousBenchmarks.cs new file mode 100644 index 0000000..82feda4 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SubscribeSynchronousBenchmarks.cs @@ -0,0 +1,147 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission cost of SubscribeSynchronous with the +/// Func<T, ValueTask> handler surface. Two variants: +/// +/// SubscribeSynchronous_TrivialHandler — handler returns default (no async machinery). +/// SubscribeSynchronous_RealisticAsyncHandler — handler is async _ => await cached; +/// the awaited task is pre-completed, so the path exercises the async builder's sync-completion +/// fast path end to end. +/// +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class SubscribeSynchronousBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 100; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 1_000; + + /// Pre-completed task awaited by the realistic-async-handler variant. + private static readonly Task _completedResult = Task.FromResult(0); + + /// Source feeding the trivial (no-await) pipeline. + private readonly Subject _trivialSource = new(); + + /// Source feeding the realistic async-handler pipeline. + private readonly Subject _realisticSource = new(); + + /// Source feeding the Task-bridged-handler comparison pipeline. + private readonly Subject _taskBridgedSource = new(); + + /// Subscription on the trivial pipeline. + private IDisposable _trivialSubscription = null!; + + /// Subscription on the realistic-async-handler pipeline. + private IDisposable _realisticSubscription = null!; + + /// Subscription on the Task-bridged-handler pipeline. + private IDisposable _taskBridgedSubscription = null!; + + /// Gets or sets the number of emissions pushed through the pipeline per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires both pipelines. + [GlobalSetup] + public void Setup() + { + _trivialSubscription = _trivialSource.SubscribeSynchronous(static _ => default); + _realisticSubscription = _realisticSource.SubscribeSynchronous(static async value => + { + await _completedResult.ConfigureAwait(false); + BlackHole(value); + }); + _taskBridgedSubscription = _taskBridgedSource.SubscribeSynchronous(static value => new ValueTask(TaskHelper(value))); + } + + /// Tears every pipeline down. + [GlobalCleanup] + public void Cleanup() + { + _trivialSubscription.Dispose(); + _realisticSubscription.Dispose(); + _taskBridgedSubscription.Dispose(); + _trivialSource.Dispose(); + _realisticSource.Dispose(); + _taskBridgedSource.Dispose(); + } + + /// Trivial handler (_ => default) — measures the operator's per-emission overhead. + [Benchmark] + public void SubscribeSynchronous_TrivialHandler() + { + for (var i = 0; i < EmissionCount; i++) + { + _trivialSource.OnNext(i); + } + } + + /// Realistic async _ => await ... handler that sync-completes against a cached + /// completed task — measures the operator + async-state-machine cost end to end. + [Benchmark] + public void SubscribeSynchronous_RealisticAsyncHandler() + { + for (var i = 0; i < EmissionCount; i++) + { + _realisticSource.OnNext(i); + } + } + + /// Comparison benchmark: handler bridges from a -returning helper + /// method via new ValueTask(helper(x)). The helper is async Task; the value-task + /// wrap is a struct constructor (free). Proves Task-vs-ValueTask handler perf is neutral — + /// the async state machine box dominates in both shapes. + [Benchmark] + public void SubscribeSynchronous_TaskBridgedHandler() + { + for (var i = 0; i < EmissionCount; i++) + { + _taskBridgedSource.OnNext(i); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// Cached -returning helper used by . + /// The emitted value (ignored — the helper exercises the await machinery only). + /// A pre-completed task. + private static async Task TaskHelper(int value) + { + _ = value; + await _completedResult.ConfigureAwait(false); + } + + /// Prevents the JIT from elide-ing the awaited value. + /// The value to consume. + private static void BlackHole(int value) => _ = value; +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SyncFilterAndProjectionBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SyncFilterAndProjectionBenchmarks.cs index 22bdffd..fd69b5e 100644 --- a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SyncFilterAndProjectionBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SyncFilterAndProjectionBenchmarks.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reactive; +using System.Text.RegularExpressions; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; using ReactiveUI.Extensions.Internal; @@ -20,7 +21,7 @@ namespace ReactiveUI.Extensions.Benchmarks; [SimpleJob(RuntimeMoniker.Net10_0)] [MemoryDiagnoser] [MarkdownExporterAttribute.GitHub] -public class SyncFilterAndProjectionBenchmarks : IDisposable +public partial class SyncFilterAndProjectionBenchmarks : IDisposable { /// Low end of the parameter sweep. private const int SmallEmissionCount = 1_000; @@ -31,6 +32,21 @@ public class SyncFilterAndProjectionBenchmarks : IDisposable /// Pre-boxed non-null reference reused across emissions. private const string SharedNonNull = "x"; + /// A value that matches the digit pattern so every emission passes the regex filter. + private const string MatchingString = "abc123"; + + /// Match-timeout (milliseconds) shared by both the runtime-compiled and source-generated regexes. + private const int RegexTimeoutMs = 1000; + + /// Runtime regex reused across emissions. + private readonly Regex _compiledRegex = new(@"\d+", RegexOptions.Compiled, TimeSpan.FromMilliseconds(RegexTimeoutMs)); + + /// Source for the runtime-compiled Filter(Regex) pipeline. + private readonly CurrentValueSubject _compiledFilterSource = new(MatchingString); + + /// Source for the source-generated Filter(Regex) pipeline. + private readonly CurrentValueSubject _generatedFilterSource = new(MatchingString); + /// Source for the AsSignal pipeline; initial-value replay fires once during setup. private readonly CurrentValueSubject _signalSource = new(0); @@ -64,6 +80,12 @@ public class SyncFilterAndProjectionBenchmarks : IDisposable /// Subscription on the WhereIsNotNull pipeline. private IDisposable _whereNotNullSubscription = null!; + /// Subscription on the runtime-compiled Filter(Regex) pipeline. + private IDisposable _compiledFilterSubscription = null!; + + /// Subscription on the source-generated Filter(Regex) pipeline. + private IDisposable _generatedFilterSubscription = null!; + /// Gets or sets the number of emissions pushed through each pipeline per benchmark invocation. [Params(SmallEmissionCount, LargeEmissionCount)] public int EmissionCount { get; set; } @@ -76,6 +98,8 @@ public void Setup() _notSubscription = _notSource.Not().Subscribe(_boolSink); _whereTrueSubscription = _whereTrueSource.WhereTrue().Subscribe(_boolSink); _whereNotNullSubscription = _whereNotNullSource.WhereIsNotNull().Subscribe(_stringSink); + _compiledFilterSubscription = _compiledFilterSource.Filter(_compiledRegex).Subscribe(_stringSink); + _generatedFilterSubscription = _generatedFilterSource.Filter(DigitsRegex()).Subscribe(_stringSink); } /// Tears each pipeline down. @@ -94,6 +118,10 @@ public void Cleanup() _whereTrueSource.Dispose(); _whereNotNullSubscription.Dispose(); _whereNotNullSource.Dispose(); + _compiledFilterSubscription.Dispose(); + _compiledFilterSource.Dispose(); + _generatedFilterSubscription.Dispose(); + _generatedFilterSource.Dispose(); } /// Drives values through AsSignal; every emission becomes . @@ -126,6 +154,28 @@ public void WhereTrue_AllPassing() } } + /// Drives a string matching the runtime regex through + /// Filter(Regex) (every value passes). + [Benchmark] + public void Filter_CompiledRegexAllMatching() + { + for (var i = 0; i < EmissionCount; i++) + { + _compiledFilterSource.OnNext(MatchingString); + } + } + + /// Drives a string matching the source-generated regex through Filter(Regex) + /// (every value passes). + [Benchmark] + public void Filter_GeneratedRegexAllMatching() + { + for (var i = 0; i < EmissionCount; i++) + { + _generatedFilterSource.OnNext(MatchingString); + } + } + /// Drives a shared non-null reference through WhereIsNotNull (every value passes). [Benchmark] public void WhereIsNotNull_AllPassing() @@ -155,6 +205,13 @@ protected virtual void Dispose(bool disposing) Cleanup(); } + /// Source-generated counterpart of ; the + /// emits the matcher at compile time rather than building + /// it via reflection emit at first use. + /// A cached, source-generated regex matching one-or-more ASCII digits. + [GeneratedRegex(@"\d+", RegexOptions.None, RegexTimeoutMs)] + private static partial Regex DigitsRegex(); + /// No-op observer used as the terminal sink for each pipeline. /// The element type the sink consumes. private sealed class NoopObserver : IObserver diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SyncSelectFusionBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SyncSelectFusionBenchmarks.cs new file mode 100644 index 0000000..8b18820 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SyncSelectFusionBenchmarks.cs @@ -0,0 +1,205 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Per-emission steady-state cost of the fused sync operators that replace common multi-stage +/// LINQ-style chains: SelectConstant, WhereSelect, TrySelect, and +/// SelectManyThen. The fused versions exist to elide intermediate observer allocations; +/// the benchmark locks in their zero-alloc steady state. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class SyncSelectFusionBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Constant value emitted by the pipeline. + private const int ConstantValue = 1; + + /// Cached non-null reference returned by the TrySelect projection. + private const string TrySelectSentinel = "x"; + + /// Pre-built inner observable reused by the SelectManyThen pipeline. + private static readonly IObservable _innerObservable = new InlineSingleValueObservable(0); + + /// Source for the SelectConstant pipeline. + private readonly Subject _selectConstantSource = new(); + + /// Source for the WhereSelect pipeline. + private readonly Subject _whereSelectSource = new(); + + /// Source for the TrySelect pipeline. + private readonly Subject _trySelectSource = new(); + + /// Source for the SelectManyThen pipeline. + private readonly Subject _selectManyThenSource = new(); + + /// Reused sinks. + private readonly NoopObserver _intSink = new(); + + /// Reused sinks. + private readonly NoopObserver _stringSink = new(); + + /// Subscription on the SelectConstant pipeline. + private IDisposable _selectConstantSubscription = null!; + + /// Subscription on the WhereSelect pipeline. + private IDisposable _whereSelectSubscription = null!; + + /// Subscription on the TrySelect pipeline. + private IDisposable _trySelectSubscription = null!; + + /// Subscription on the SelectManyThen pipeline. + private IDisposable _selectManyThenSubscription = null!; + + /// Gets or sets the number of emissions pushed through each pipeline per invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires every pipeline. + [GlobalSetup] + public void Setup() + { + _selectConstantSubscription = _selectConstantSource.SelectConstant(ConstantValue).Subscribe(_intSink); + _whereSelectSubscription = _whereSelectSource + .WhereSelect(static x => (x & 1) == 0, static x => x + 1) + .Subscribe(_intSink); + _trySelectSubscription = _trySelectSource + .TrySelect(static _ => TrySelectSentinel) + .Subscribe(_stringSink); + _selectManyThenSubscription = _selectManyThenSource + .SelectManyThen(static _ => _innerObservable, static _ => _innerObservable) + .Subscribe(_intSink); + } + + /// Tears every pipeline down. + [GlobalCleanup] + public void Cleanup() + { + _selectConstantSubscription.Dispose(); + _whereSelectSubscription.Dispose(); + _trySelectSubscription.Dispose(); + _selectManyThenSubscription.Dispose(); + _selectConstantSource.Dispose(); + _whereSelectSource.Dispose(); + _trySelectSource.Dispose(); + _selectManyThenSource.Dispose(); + } + + /// Drives values through the SelectConstant pipeline. + [Benchmark] + public void SelectConstant_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _selectConstantSource.OnNext(i); + } + } + + /// Drives values through the WhereSelect pipeline (predicate alternates). + [Benchmark] + public void WhereSelect_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _whereSelectSource.OnNext(i); + } + } + + /// Drives values through the TrySelect pipeline (always returns non-null). + [Benchmark] + public void TrySelect_AllNonNull() + { + for (var i = 0; i < EmissionCount; i++) + { + _trySelectSource.OnNext(i); + } + } + + /// Drives values through the fused SelectManyThen pipeline. + [Benchmark] + public void SelectManyThen_PerEmission() + { + for (var i = 0; i < EmissionCount; i++) + { + _selectManyThenSource.OnNext(i); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// Synchronously emits a single value and completes inside the subscribe call. + /// The element type. + /// The value emitted on every subscribe. + private sealed class InlineSingleValueObservable(T value) : IObservable + { + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnNext(value); + observer.OnCompleted(); + return EmptyDisposable.Instance; + } + } + + /// No-op observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } + + /// Singleton no-op disposable. + private sealed class EmptyDisposable : IDisposable + { + /// Singleton instance. + public static readonly EmptyDisposable Instance = new(); + + /// + public void Dispose() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SynchronizeSynchronousBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SynchronizeSynchronousBenchmarks.cs new file mode 100644 index 0000000..e6afe95 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/SynchronizeSynchronousBenchmarks.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-emission cost of SynchronizeSynchronous, the synchronous counterpart +/// to SynchronizeAsync. The downstream sink disposes the per-emission Sync handle inline. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class SynchronizeSynchronousBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 100; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 1_000; + + /// Source feeding the SynchronizeSynchronous pipeline. + private readonly Subject _source = new(); + + /// Subscription on the SynchronizeSynchronous pipeline. + private IDisposable _subscription = null!; + + /// Gets or sets the number of emissions pushed through the pipeline per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Wires the SynchronizeSynchronous pipeline; the sink disposes the Sync handle inline. + [GlobalSetup] + public void Setup() => + _subscription = _source.SynchronizeSynchronous().Subscribe(static tuple => tuple.Sync.Dispose()); + + /// Tears the pipeline down. + [GlobalCleanup] + public void Cleanup() + { + _subscription.Dispose(); + _source.Dispose(); + } + + /// Drives values through the SynchronizeSynchronous pipeline. + [Benchmark] + public void SynchronizeSynchronous_FastDispose() + { + for (var i = 0; i < EmissionCount; i++) + { + _source.OnNext(i); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/TimedSyncOperatorSubscribeBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/TimedSyncOperatorSubscribeBenchmarks.cs new file mode 100644 index 0000000..329d017 --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/TimedSyncOperatorSubscribeBenchmarks.cs @@ -0,0 +1,133 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Subjects; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Subscribe-and-dispose cost for the scheduler-driven sync operators that don't yield meaningful +/// per-emission measurements without real wall-clock time: Heartbeat, ThrottleFirst, +/// Conflate, and While. The benchmark exercises only the per-subscription wrapper +/// allocation; steady-state timing-driven emission isn't covered because scheduler noise dominates. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class TimedSyncOperatorSubscribeBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallInvocationCount = 100; + + /// High end of the parameter sweep. + private const int LargeInvocationCount = 1_000; + + /// Long-enough scheduler window so no emission fires during the bench's subscribe-and-dispose lifetime. + private static readonly TimeSpan _longWindow = TimeSpan.FromSeconds(60); + + /// Static condition delegate for the While bench; always returns so the loop body never fires. + private static readonly Func _falseCondition = static () => false; + + /// Static no-op action for the While bench. + private static readonly Action _noopAction = static () => { }; + + /// Source feeding the timed pipelines. + private readonly Subject _source = new(); + + /// Reusable sinks for the per-pipeline subscribes. + private readonly NoopObserver _intSink = new(); + + /// Reusable sink for Heartbeat<Heartbeat<int>>. + private readonly NoopObserver> _heartbeatSink = new(); + + /// Reusable sink for Unit-emitting While. + private readonly NoopObserver _unitSink = new(); + + /// Gets or sets the number of subscribe-and-dispose cycles per benchmark invocation. + [Params(SmallInvocationCount, LargeInvocationCount)] + public int InvocationCount { get; set; } + + /// Loops Heartbeat subscribe → dispose with a 60s scheduler window (no emission fires). + [Benchmark] + public void Heartbeat_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.Heartbeat(_longWindow, Scheduler.Default).Subscribe(_heartbeatSink); + } + } + + /// Loops ThrottleFirst subscribe → dispose with a 60s scheduler window. + [Benchmark] + public void ThrottleFirst_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.ThrottleFirst(_longWindow, Scheduler.Default).Subscribe(_intSink); + } + } + + /// Loops Conflate subscribe → dispose with a 60s scheduler window. + [Benchmark] + public void Conflate_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = _source.Conflate(_longWindow, Scheduler.Default).Subscribe(_intSink); + } + } + + /// Loops While subscribe → dispose with a false condition (loop body never fires). + [Benchmark] + public void While_SubscribeAndDispose() + { + for (var i = 0; i < InvocationCount; i++) + { + using var sub = ReactiveExtensions.While(_falseCondition, _noopAction, Scheduler.Default).Subscribe(_unitSink); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _source.Dispose(); + } + + /// No-op observer. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ToHotTaskBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ToHotTaskBenchmarks.cs index d217084..c3ab86f 100644 --- a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ToHotTaskBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ToHotTaskBenchmarks.cs @@ -43,6 +43,17 @@ public void ToHotTask_ResolveFirstValue() } } + /// Resolves the pooled-source variant the same number of times. + /// A task that completes when every value task has been consumed. + [Benchmark] + public async Task ToHotValueTask_ResolveFirstValue() + { + for (var i = 0; i < ResolveCount; i++) + { + _ = await _source.ToHotValueTask().ConfigureAwait(false); + } + } + /// public void Dispose() { diff --git a/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ToPropertyObservableBenchmarks.cs b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ToPropertyObservableBenchmarks.cs new file mode 100644 index 0000000..0b48b5e --- /dev/null +++ b/src/benchmarks/ReactiveUI.Extensions.Benchmarks/ToPropertyObservableBenchmarks.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.ComponentModel; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; + +namespace ReactiveUI.Extensions.Benchmarks; + +/// +/// Measures the per-change cost of ToPropertyObservable: a property getter is bridged into an +/// that emits whenever the named property raises +/// . The benchmark drives change notifications +/// through the subscribed observable, capturing the per-notification overhead (name match + compiled +/// getter invocation + emit) rather than the one-time expression compilation done at subscribe time. +/// +[SimpleJob(RuntimeMoniker.Net10_0)] +[MemoryDiagnoser] +[MarkdownExporterAttribute.GitHub] +public class ToPropertyObservableBenchmarks : IDisposable +{ + /// Low end of the parameter sweep. + private const int SmallEmissionCount = 1_000; + + /// High end of the parameter sweep. + private const int LargeEmissionCount = 10_000; + + /// Synchronous no-op sink subscribed to the property observable. + private readonly NoopObserver _sink = new(); + + /// INPC source whose property the observable tracks. + private readonly ObservableModel _model = new(); + + /// Subscription on the property observable. + private IDisposable _subscription = null!; + + /// Gets or sets the number of property changes raised per benchmark invocation. + [Params(SmallEmissionCount, LargeEmissionCount)] + public int EmissionCount { get; set; } + + /// Builds the property observable and attaches the sink. + [GlobalSetup] + public void Setup() => + _subscription = _model.ToPropertyObservable(static x => x.Value).Subscribe(_sink); + + /// Tears the subscription down. + [GlobalCleanup] + public void Cleanup() => _subscription.Dispose(); + + /// Raises property changes through the observable. + [Benchmark] + public void ToPropertyObservable_PerChange() + { + for (var i = 0; i < EmissionCount; i++) + { + _model.Value = i; + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Drains synchronous teardown. + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + Cleanup(); + } + + /// Minimal model with a single tracked property. + private sealed class ObservableModel : INotifyPropertyChanged + { + /// Cached event args so each raise does not allocate — keeps the benchmark measuring + /// the operator's per-change overhead rather than the model's notification allocation. + private static readonly PropertyChangedEventArgs ValueChangedArgs = new(nameof(Value)); + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the tracked value; setting it raises . + public int Value + { + get; + set + { + field = value; + PropertyChanged?.Invoke(this, ValueChangedArgs); + } + } + } + + /// No-op synchronous observer used as the terminal sink. + /// The element type. + private sealed class NoopObserver : IObserver + { + /// + public void OnNext(T value) + { + } + + /// + public void OnError(Exception error) + { + } + + /// + public void OnCompleted() + { + } + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/DisposableAsyncSlotTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/DisposableAsyncSlotTests.cs new file mode 100644 index 0000000..01e02f6 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/DisposableAsyncSlotTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Extensions.Async.Disposables; + +namespace ReactiveUI.Extensions.Tests.Async; + +/// Tests for the zero-allocation swap / single-assignment / +/// dispose helpers that operate against a caller-owned field. +public class DisposableAsyncSlotTests +{ + /// Verifies a swap stores the new value and asynchronously disposes the previous occupant. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwapReplacesOccupant_ThenPreviousDisposed() + { + IAsyncDisposable? slot = null; + var first = new RecordingAsyncDisposable(); + var second = new RecordingAsyncDisposable(); + + await DisposableAsyncSlot.SwapAsync(ref slot, first); + await DisposableAsyncSlot.SwapAsync(ref slot, second); + + await Assert.That(first.DisposeCount).IsEqualTo(1); + await Assert.That(second.DisposeCount).IsEqualTo(0); + await Assert.That(DisposableAsyncSlot.IsDisposed(slot)).IsFalse(); + } + + /// Verifies swapping into an already-disposed slot disposes the incoming value immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSwapAfterDispose_ThenIncomingValueDisposed() + { + IAsyncDisposable? slot = null; + await DisposableAsyncSlot.DisposeAsync(ref slot); + + var late = new RecordingAsyncDisposable(); + await DisposableAsyncSlot.SwapAsync(ref slot, late); + + await Assert.That(late.DisposeCount).IsEqualTo(1); + await Assert.That(DisposableAsyncSlot.IsDisposed(slot)).IsTrue(); + } + + /// Verifies a single assignment stores the value and disposing the slot disposes it once, + /// with a second dispose being a no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAssignedThenDisposedTwice_ThenDisposedExactlyOnce() + { + IAsyncDisposable? slot = null; + var disposable = new RecordingAsyncDisposable(); + + await DisposableAsyncSlot.AssignAsync(ref slot, disposable); + await DisposableAsyncSlot.DisposeAsync(ref slot); + await DisposableAsyncSlot.DisposeAsync(ref slot); + + await Assert.That(disposable.DisposeCount).IsEqualTo(1); + } + + /// Verifies assigning into an already-disposed slot disposes the incoming value immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAssignAfterDispose_ThenIncomingValueDisposed() + { + IAsyncDisposable? slot = null; + await DisposableAsyncSlot.DisposeAsync(ref slot); + + var late = new RecordingAsyncDisposable(); + await DisposableAsyncSlot.AssignAsync(ref slot, late); + + await Assert.That(late.DisposeCount).IsEqualTo(1); + } + + /// Verifies assigning a value into an already-disposed slot is a silent + /// no-op (exercises the null-value arm of the immediate-dispose path). + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAssignNullAfterDispose_ThenNoOp() + { + IAsyncDisposable? slot = null; + await DisposableAsyncSlot.DisposeAsync(ref slot); + + await DisposableAsyncSlot.AssignAsync(ref slot, null); + + await Assert.That(DisposableAsyncSlot.IsDisposed(slot)).IsTrue(); + } + + /// Verifies a second assignment onto an occupied slot throws. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAssignedTwice_ThenThrowsInvalidOperation() + { + IAsyncDisposable? slot = null; + await DisposableAsyncSlot.AssignAsync(ref slot, new RecordingAsyncDisposable()); + + var threw = false; + try + { + await DisposableAsyncSlot.AssignAsync(ref slot, new RecordingAsyncDisposable()); + } + catch (InvalidOperationException) + { + threw = true; + } + + await Assert.That(threw).IsTrue(); + } + + /// Verifies the disposed sentinel's no-op completes silently. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedSentinelDisposed_ThenCompletesSilently() => + await ((IAsyncDisposable)DisposableAsyncSlot.DisposedSentinel.Instance).DisposeAsync(); + + /// Recording async disposable that counts disposals. + private sealed class RecordingAsyncDisposable : IAsyncDisposable + { + /// Gets the number of times this instance has been disposed. + public int DisposeCount { get; private set; } + + /// + public ValueTask DisposeAsync() + { + DisposeCount++; + return default; + } + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/DisposableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/DisposableTests.cs index ea243f0..19b63f3 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/DisposableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/DisposableTests.cs @@ -92,6 +92,17 @@ public async Task WhenCompositeDisposableAsync_ThenDisposesAll() public void WhenCompositeDisposableAsyncNegativeCapacity_ThenThrowsArgumentOutOfRange() => Assert.Throws(() => _ = new CompositeDisposableAsync(-1)); + /// Tests CompositeDisposableAsync with zero capacity leaves the backing array unallocated. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCompositeDisposableAsyncZeroCapacity_ThenEmpty() + { + var composite = new CompositeDisposableAsync(0); + + await Assert.That(composite.Count).IsEqualTo(0); + await composite.DisposeAsync(); + } + /// Tests CompositeDisposableAsync with capacity works. /// A representing the asynchronous test operation. [Test] @@ -976,6 +987,124 @@ public async Task WhenCopyToWithInsufficientSpace_ThenThrows() await composite.DisposeAsync(); } + /// Tests the constructor sizes exactly and disposes all members. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCompositeDisposableAsyncFromCollection_ThenDisposesAll() + { + var count = 0; + List list = + [ + DisposableAsync.Create(() => + { + Interlocked.Increment(ref count); + return default; + }), + DisposableAsync.Create(() => + { + Interlocked.Increment(ref count); + return default; + }), + ]; + + const int ExpectedCount = 2; + var composite = new CompositeDisposableAsync(list); + await Assert.That(composite.Count).IsEqualTo(ExpectedCount); + + await composite.DisposeAsync(); + await Assert.That(count).IsEqualTo(ExpectedCount); + } + + /// Tests the constructor with an empty collection yields an empty composite. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCompositeDisposableAsyncFromEmptyCollection_ThenEmpty() + { + List empty = []; + var composite = new CompositeDisposableAsync(empty); + + await Assert.That(composite.Count).IsEqualTo(0); + await composite.DisposeAsync(); + } + + /// Tests the params constructor with an empty array yields an empty composite. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCompositeDisposableAsyncFromEmptyParams_ThenEmpty() + { + IAsyncDisposable[] empty = []; + var composite = new CompositeDisposableAsync(empty); + + await Assert.That(composite.Count).IsEqualTo(0); + await composite.DisposeAsync(); + } + + /// Tests that the backing array grows beyond the default capacity and compacts after enough + /// removals, then enumerates a snapshot of the survivors and disposes them. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenGrownThenCompacted_ThenSurvivorsEnumeratedAndDisposed() + { + const int Total = 20; + const int Removed = 16; + var disposed = 0; + var composite = new CompositeDisposableAsync(); + var items = new IAsyncDisposable[Total]; + + for (var i = 0; i < Total; i++) + { + items[i] = DisposableAsync.Create(() => + { + Interlocked.Increment(ref disposed); + return default; + }); + await composite.AddAsync(items[i]); + } + + await Assert.That(composite.Count).IsEqualTo(Total); + + for (var i = 0; i < Removed; i++) + { + await composite.Remove(items[i]); + } + + await Assert.That(composite.Count).IsEqualTo(Total - Removed); + + var enumerated = 0; + using (var enumerator = composite.GetEnumerator()) + { + while (enumerator.MoveNext()) + { + enumerated++; + } + } + + await Assert.That(enumerated).IsEqualTo(Total - Removed); + + await composite.DisposeAsync(); + await Assert.That(disposed).IsEqualTo(Total); + } + + /// Tests that enumerating an empty composite yields no elements. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenEnumeratedEmpty_ThenNoElements() + { + var composite = new CompositeDisposableAsync(); + + var enumerated = 0; + using (var enumerator = composite.GetEnumerator()) + { + while (enumerator.MoveNext()) + { + enumerated++; + } + } + + await Assert.That(enumerated).IsEqualTo(0); + await composite.DisposeAsync(); + } + /// /// Helper disposable for testing ToDisposableAsync. /// diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs index 0a5d638..875e34f 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs @@ -341,6 +341,27 @@ public async Task WhenObserverOnCompletedCoreThrowsAsync_ThenRoutedToUnhandled() await Assert.That(unhandled!.Message).IsEqualTo("async-throw"); } + /// Verifies that linking an observer's own dispose token short-circuits as a no-op (the + /// token already drives the dispose chain) and leaves the observer usable. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLinkingOwnDisposeToken_ThenSelfLinkShortCircuits() + { + var received = new List(); + await using var observer = new TestableObserverAsync( + onNextAsyncCore: (value, _) => + { + received.Add(value); + return default; + }); + + observer.LinkOwnDisposeToken(); + + await observer.OnNextAsync(SampleValue, CancellationToken.None); + + await Assert.That(received).IsCollectionEqualTo([SampleValue]); + } + /// /// A concrete implementation for testing, with /// configurable behavior for each virtual method. @@ -353,6 +374,10 @@ internal sealed class TestableObserverAsync( Func? onErrorResumeAsyncCore = null, Func? onCompletedAsyncCore = null) : ObserverAsync { + /// Links the observer's own dispose token into its link chain, exercising the + /// self-link short-circuit in LinkExternalCancellation. + public void LinkOwnDisposeToken() => LinkExternalCancellation(InternalDisposedToken); + /// protected override ValueTask OnNextAsyncCore(int value, CancellationToken cancellationToken) => onNextAsyncCore is not null ? onNextAsyncCore(value, cancellationToken) : default; diff --git a/src/tests/ReactiveUI.Extensions.Tests/ContinuationTests.cs b/src/tests/ReactiveUI.Extensions.Tests/ContinuationTests.cs new file mode 100644 index 0000000..1b91512 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/ContinuationTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Extensions.Tests; + +/// Tests for — the phase-barrier lock used to serialise emissions, +/// covering both the (Task) and +/// (ValueTask) entry points plus the already-locked short-circuit. +public class ContinuationTests +{ + /// Guard timeout to keep barrier rendezvous from hanging the test run. + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + /// Verifies pushes the item downstream, locks, + /// and completes once the phase is signalled by an unlock. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockValueTaskNotLocked_ThenEmitsAndCompletesOnUnlock() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var lockTask = continuation.LockValueTask(1, observer); + var unlockTask = continuation.UnLock(); + + await lockTask.AsTask().WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + await Assert.That(values[0]).IsEqualTo(1); + await Assert.That(continuation.CompletedPhases).IsGreaterThanOrEqualTo(1); + } + + /// Verifies a second while already locked returns a + /// completed default value task and does not push the item downstream. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockValueTaskAlreadyLocked_ThenReturnsDefaultAndDropsItem() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var first = continuation.LockValueTask(1, observer); + var second = continuation.LockValueTask(2, observer); + + await Assert.That(second.IsCompleted).IsTrue(); + await second; + + var unlockTask = continuation.UnLock(); + await first.AsTask().WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + await Assert.That(values[0]).IsEqualTo(1); + } + + /// Verifies (the Task overload) emits and completes on unlock. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockNotLocked_ThenEmitsAndCompletesOnUnlock() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var lockTask = continuation.Lock(1, observer); + var unlockTask = continuation.UnLock(); + + await lockTask.WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + } + + /// Verifies a second while already locked returns a completed + /// task and drops the item. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenLockAlreadyLocked_ThenReturnsCompletedAndDropsItem() + { + using var continuation = new Continuation(); + var values = new List(); + var observer = Observer.Create<(int Value, IDisposable Sync)>(v => values.Add(v.Value)); + + var first = continuation.Lock(1, observer); + var second = continuation.Lock(2, observer); + + await Assert.That(second.IsCompleted).IsTrue(); + + var unlockTask = continuation.UnLock(); + await first.WaitAsync(Timeout); + await unlockTask.WaitAsync(Timeout); + + await Assert.That(values.Count).IsEqualTo(1); + } + + /// Verifies that unlocking a continuation that was never locked completes immediately. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenUnlockNotLocked_ThenCompletesImmediately() + { + using var continuation = new Continuation(); + + await continuation.UnLock().WaitAsync(Timeout); + + await Assert.That(continuation.CompletedPhases).IsEqualTo(0); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs new file mode 100644 index 0000000..3899de5 --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsValueTaskHelperTests.cs @@ -0,0 +1,172 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Extensions.Internal; + +namespace ReactiveUI.Extensions.Tests.Internal; + +/// Tests for covering the value, error, and +/// empty-completion paths the pooled ToHotValueTask source exposes, plus the pool reuse and +/// post-settle drop branches. +public class FirstAsValueTaskHelperTests +{ + /// Value used by the latch-on-first-emission tests. + private const int FirstValue = 7; + + /// Value used to verify subsequent values are ignored. + private const int SecondValue = 11; + + /// Verifies the helper completes with the first value the source emits. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceEmits_ThenValueTaskCompletesWithFirst() + { + var subject = new Subject(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(subject); + + subject.OnNext(FirstValue); + subject.OnNext(SecondValue); + subject.OnCompleted(); + + await Assert.That(await task).IsEqualTo(FirstValue); + } + + /// Verifies the helper faults the value task when the source errors before any value. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceErrors_ThenValueTaskFaults() + { + var expected = new InvalidOperationException("boom"); + var task = FirstAsValueTaskHelper.FirstAsValueTask(Observable.Throw(expected)); + + var ex = await Assert.ThrowsAsync(async () => await task); + await Assert.That(ex).IsSameReferenceAs(expected); + } + + /// Verifies the helper faults the value task when the source completes empty. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletesEmpty_ThenValueTaskFaultsWithInvalidOperation() + { + var task = FirstAsValueTaskHelper.FirstAsValueTask(Observable.Empty()); + + await Assert.ThrowsAsync(async () => await task); + } + + /// Verifies the helper throws when the source argument is null. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceNull_ThenThrowsArgumentNullException() => + await Assert.ThrowsAsync( + static async () => await FirstAsValueTaskHelper.FirstAsValueTask(null!)); + + /// Exercises the Subscription?.Dispose() null-conditional branch when a source + /// synchronously emits during Subscribe before the subscription field is assigned. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncSourceEmits_ThenSubscriptionNullBranchSkipsDispose() + { + const int Sentinel = 17; + + var value = await FirstAsValueTaskHelper.FirstAsValueTask(Observable.Return(Sentinel)); + + await Assert.That(value).IsEqualTo(Sentinel); + } + + /// Verifies the pooled source is reused across sequential calls — a second call after the + /// first has settled returns to the pool and resolves correctly. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenCalledSequentially_ThenPooledSourceReused() + { + var first = await FirstAsValueTaskHelper.FirstAsValueTask(Observable.Return(FirstValue)); + var second = await FirstAsValueTaskHelper.FirstAsValueTask(Observable.Return(SecondValue)); + + await Assert.That(first).IsEqualTo(FirstValue); + await Assert.That(second).IsEqualTo(SecondValue); + } + + /// Verifies awaiting the value task before the source emits registers a continuation on the + /// pooled source (the incomplete-await path) and resolves once the value later arrives. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenAwaitedBeforeEmission_ThenCompletesOnLaterValue() + { + var subject = new Subject(); + var pending = FirstAsValueTaskHelper.FirstAsValueTask(subject).AsTask(); + + subject.OnNext(FirstValue); + + var result = await pending.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.That(result).IsEqualTo(FirstValue); + } + + /// Verifies emissions arriving after the value task has already settled are silently ignored. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondTerminalAfterSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(source); + + source.Observer.OnNext(FirstValue); + source.Observer.OnNext(SecondValue); + source.Observer.OnError(new InvalidOperationException("ignored")); + source.Observer.OnCompleted(); + + await Assert.That(await task).IsEqualTo(FirstValue); + } + + /// Verifies a second OnError arriving after the first is dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondOnErrorAfterSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(source); + var expected = new InvalidOperationException("first"); + + source.Observer.OnError(expected); + source.Observer.OnError(new InvalidOperationException("ignored")); + source.Observer.OnCompleted(); + + var caught = await Assert.ThrowsAsync(async () => await task); + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies a second OnCompleted arriving after the first is dropped. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSecondOnCompletedAfterSettled_ThenIgnored() + { + var source = new InvasiveObservable(); + var task = FirstAsValueTaskHelper.FirstAsValueTask(source); + + source.Observer.OnCompleted(); + source.Observer.OnCompleted(); + source.Observer.OnError(new InvalidOperationException("ignored")); + + await Assert.ThrowsAsync(async () => await task); + } + + /// Test observable that captures its subscriber so tests can directly invoke + /// non-cooperative double-terminal sequences against the pooled first-value observer. + /// The element type. + private sealed class InvasiveObservable : IObservable + { + /// The captured observer from the most recent subscription. + private IObserver? _observer; + + /// Gets the captured observer. + public IObserver Observer => _observer + ?? throw new InvalidOperationException("No subscriber yet."); + + /// + public IDisposable Subscribe(IObserver observer) + { + _observer = observer; + return System.Reactive.Disposables.Disposable.Empty; + } + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/ObservablesTests.cs b/src/tests/ReactiveUI.Extensions.Tests/ObservablesTests.cs new file mode 100644 index 0000000..30ed22d --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/ObservablesTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Extensions.Tests; + +/// Tests for the factory methods. +public class ObservablesTests +{ + /// Verifies emits the single value and completes on subscribe. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenReturn_ThenEmitsValueAndCompletes() + { + const int Value = 42; + var values = new List(); + var completed = false; + + using var sub = Observables.Return(Value).Subscribe(values.Add, () => completed = true); + + await Assert.That(values).IsCollectionEqualTo([Value]); + await Assert.That(completed).IsTrue(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs index b0cb5c6..698edb9 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ConflateObservableTests.cs @@ -186,24 +186,25 @@ public async Task WhenOnCompletedAfterError_ThenDropped() await Assert.That(completed).IsFalse(); } - /// Verifies 's - /// post-dispose Enqueue guard by constructing the marshaller directly, disposing it, - /// and then pushing a notification — exercising the defensive branch that is otherwise - /// unreachable through the front-door Conflate pipeline. + /// Verifies 's + /// post-dispose Enqueue guard by constructing the sink directly, disposing it, and then + /// pushing notifications — exercising the defensive branch that is otherwise unreachable + /// through the front-door Conflate pipeline. /// A representing the asynchronous test operation. [Test] - public async Task WhenMarshallerEnqueuedAfterDispose_ThenSilentlyDropped() + public async Task WhenSinkEnqueuedAfterDispose_ThenSilentlyDropped() { var downstream = new RecordingObserver(); var scheduler = new TestScheduler(); - var marshaller = new ReactiveUI.Extensions.Operators.ConflateObservable.SchedulerMarshaller( + var sink = new ReactiveUI.Extensions.Operators.ConflateObservable.ConflateSink( downstream, + TimeSpan.FromTicks(UpdatePeriodTicks), scheduler); - marshaller.Dispose(); - marshaller.OnNext(1); - marshaller.OnError(new InvalidOperationException("late")); - marshaller.OnCompleted(); + sink.Dispose(); + sink.OnNext(1); + sink.OnError(new InvalidOperationException("late")); + sink.OnCompleted(); scheduler.AdvanceBy(UpdatePeriodTicks); await Assert.That(downstream.Values).IsEmpty(); @@ -227,10 +228,12 @@ public async Task WhenSinkEventsAfterTerminated_ThenDropped() var expected = new InvalidOperationException("first"); sink.OnError(expected); + scheduler.AdvanceBy(UpdatePeriodTicks); sink.OnNext(1); sink.OnError(new InvalidOperationException("ignored")); sink.OnCompleted(); + scheduler.AdvanceBy(UpdatePeriodTicks); await Assert.That(downstream.Error).IsSameReferenceAs(expected); await Assert.That(downstream.Values).IsEmpty(); diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/DetectStaleObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/DetectStaleObservableTests.cs new file mode 100644 index 0000000..d781b8a --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/DetectStaleObservableTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Coverage for DetectStaleObservable's subscription-teardown branch — when the source +/// terminates synchronously during subscribe, the sink is already done by the time the upstream handle +/// is attached, so the attach disposes it instead of recording it. +public class DetectStaleObservableTests +{ + /// Staleness window used by the tests. + private const int WindowTicks = 100; + + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Verifies that a source erroring synchronously during subscribe forwards the error and + /// disposes the upstream handle through the attach-after-terminated branch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubscription() + { + var scheduler = new TestScheduler(); + var expected = new InvalidOperationException(SourceErrorMessage); + var source = new SyncErroringObservable(expected); + Exception? caught = null; + + using var sub = source.DetectStale(TimeSpan.FromTicks(WindowTicks), scheduler) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(source.Subscription.IsDisposed).IsTrue(); + } + + /// Observable that synchronously errors during Subscribe and exposes the subscription + /// handle it returned so tests can assert it was disposed. + /// The element type. + private sealed class SyncErroringObservable(Exception error) : IObservable + { + /// Gets the subscription handle returned from the most recent subscribe. + public BooleanDisposable Subscription { get; } = new(); + + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnError(error); + return Subscription; + } + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs new file mode 100644 index 0000000..96af46a --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/DoOnDisposeObservableTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Coverage for DoOnDisposeObservable — the dispose action fires exactly once even when +/// the subscription is disposed multiple times, and the upstream is torn down before the action runs. +public class DoOnDisposeObservableTests +{ + /// Verifies the dispose action fires once and the upstream is detached on first dispose, and a + /// second dispose is a no-op via the latch. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedTwice_ThenActionFiresOnce() + { + var executed = 0; + var source = new Subject(); + + var sub = source.DoOnDispose(() => executed++).Subscribe(); + await Assert.That(source.HasObservers).IsTrue(); + + sub.Dispose(); + sub.Dispose(); + + await Assert.That(executed).IsEqualTo(1); + await Assert.That(source.HasObservers).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/DropIfBusyObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/DropIfBusyObservableTests.cs index 34e57fd..6a67f91 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/DropIfBusyObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/DropIfBusyObservableTests.cs @@ -25,7 +25,7 @@ public async Task WhenDropIfBusyAsyncActionThrows_ThenForwardsError() var faulted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var expected = new InvalidOperationException(ActionFailedMessage); - using var sub = subject.DropIfBusy(_ => Task.FromException(expected)) + using var sub = subject.DropIfBusy(_ => ValueTask.FromException(expected)) .Subscribe(static _ => { }, ex => faulted.TrySetResult(ex)); subject.OnNext(TriggerValue); @@ -43,7 +43,7 @@ public async Task WhenDropIfBusySourceErrors_ThenForwardsError() Exception? caught = null; var expected = new InvalidOperationException(SourceErrorMessage); - using var sub = subject.DropIfBusy(static _ => Task.CompletedTask) + using var sub = subject.DropIfBusy(static _ => default) .Subscribe(static _ => { }, ex => caught = ex); subject.OnError(expected); @@ -59,7 +59,7 @@ public async Task WhenDropIfBusySourceCompletes_ThenForwardsCompletion() var subject = new Subject(); var completed = false; - using var sub = subject.DropIfBusy(static _ => Task.CompletedTask) + using var sub = subject.DropIfBusy(static _ => default) .Subscribe(static _ => { }, () => completed = true); subject.OnCompleted(); diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/ObserveOnObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/ObserveOnObservableTests.cs new file mode 100644 index 0000000..51b22db --- /dev/null +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/ObserveOnObservableTests.cs @@ -0,0 +1,173 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.Reactive.Testing; + +namespace ReactiveUI.Extensions.Tests.Operators; + +/// Coverage for ObserveOnObservable (reached via ObserveOnSafe) — the +/// immediate-scheduler passthrough, the queue-and-drain marshaller's value / error / completion +/// forwarding, dispose teardown, and the attach-after-terminated branch of the shared drain state. +public class ObserveOnObservableTests +{ + /// Synthetic error message attached to source errors. + private const string SourceErrorMessage = "source error"; + + /// Second sentinel value (kept as a constant to satisfy the no-magic-number rule). + private const int SecondValue = 2; + + /// Verifies the immediate scheduler is special-cased to forward straight through the source + /// subscription without the queue-and-drain machinery. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenImmediateScheduler_ThenForwardsDirectly() + { + var source = new Subject(); + var values = new List(); + var completed = false; + + using var sub = source.ObserveOnSafe(ImmediateScheduler.Instance) + .Subscribe(values.Add, () => completed = true); + + source.OnNext(1); + source.OnNext(SecondValue); + source.OnCompleted(); + + await Assert.That(values).IsCollectionEqualTo([1, SecondValue]); + await Assert.That(completed).IsTrue(); + } + + /// Verifies queued values are drained downstream in FIFO order on the scheduler thread. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenValuesMarshalled_ThenForwardedInOrderOnDrain() + { + var scheduler = new TestScheduler(); + var source = new Subject(); + var values = new List(); + + using var sub = source.ObserveOnSafe(scheduler).Subscribe(values.Add); + + source.OnNext(1); + source.OnNext(SecondValue); + + // Nothing forwarded until the scheduled drain pass runs. + await Assert.That(values).IsEmpty(); + + scheduler.AdvanceBy(1); + + await Assert.That(values).IsCollectionEqualTo([1, SecondValue]); + } + + /// Verifies a source error is forwarded through the scheduler marshaller. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceErrors_ThenForwardsError() + { + var scheduler = new TestScheduler(); + var source = new Subject(); + Exception? caught = null; + var expected = new InvalidOperationException(SourceErrorMessage); + + using var sub = source.ObserveOnSafe(scheduler).Subscribe(static _ => { }, ex => caught = ex); + + source.OnError(expected); + scheduler.AdvanceBy(1); + + await Assert.That(caught).IsSameReferenceAs(expected); + } + + /// Verifies source completion is forwarded through the scheduler marshaller. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceCompletes_ThenForwardsCompletion() + { + var scheduler = new TestScheduler(); + var source = new Subject(); + var completed = false; + + using var sub = source.ObserveOnSafe(scheduler).Subscribe(static _ => { }, () => completed = true); + + source.OnCompleted(); + scheduler.AdvanceBy(1); + + await Assert.That(completed).IsTrue(); + } + + /// Verifies disposing tears down the upstream subscription and stops forwarding queued values. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenDisposedBeforeDrain_ThenTearsDownAndDropsQueued() + { + var scheduler = new TestScheduler(); + var source = new Subject(); + var values = new List(); + + var sub = source.ObserveOnSafe(scheduler).Subscribe(values.Add); + + source.OnNext(1); + await Assert.That(source.HasObservers).IsTrue(); + + sub.Dispose(); + await Assert.That(source.HasObservers).IsFalse(); + + scheduler.AdvanceBy(1); + await Assert.That(values).IsEmpty(); + } + + /// Verifies the upstream subscription is disposed when the source terminates synchronously during + /// subscribe — the drain runs inline (terminating the sink) before AttachSourceSubscription records + /// the handle, so the late attach disposes it instead. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSourceTerminatesDuringSubscribe_ThenLateAttachDisposesSubscription() + { + var expected = new InvalidOperationException(SourceErrorMessage); + var source = new SyncErroringObservable(expected); + Exception? caught = null; + + using var sub = source.ObserveOnSafe(new InlineScheduler()) + .Subscribe(static _ => { }, ex => caught = ex); + + await Assert.That(caught).IsSameReferenceAs(expected); + await Assert.That(source.Subscription.IsDisposed).IsTrue(); + } + + /// Observable that synchronously errors during Subscribe and exposes the subscription + /// handle it returned so tests can assert it was disposed. + /// The element type. + private sealed class SyncErroringObservable(Exception error) : IObservable + { + /// Gets the subscription handle returned from the most recent subscribe. + public BooleanDisposable Subscription { get; } = new(); + + /// + public IDisposable Subscribe(IObserver observer) + { + observer.OnError(error); + return Subscription; + } + } + + /// Scheduler that runs scheduled work synchronously on the calling thread, so a drain pass + /// executes inline during the schedule call. Distinct instance from + /// so the operator's immediate-scheduler passthrough does not apply. + private sealed class InlineScheduler : IScheduler + { + /// + public DateTimeOffset Now => DateTimeOffset.MinValue; + + /// + public IDisposable Schedule(TState state, Func action) => + action(this, state); + + /// + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) => + action(this, state); + + /// + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => + action(this, state); + } +} diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs index a225198..a775cfb 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/OperatorAfterTerminalGuardTests.cs @@ -231,7 +231,7 @@ public async Task WhenDropIfBusyEventsAfterCompleted_ThenDropped() var values = new List(); var completedCount = 0; - using var sub = source.DropIfBusy(static _ => Task.CompletedTask) + using var sub = source.DropIfBusy(static _ => default) .Subscribe(values.Add, () => completedCount++); source.Observer.OnCompleted(); @@ -522,7 +522,7 @@ public async Task WhenSubscribeSynchronousOmitsErrorAndCompletedCallbacks_ThenNu using var sub = subject.SubscribeSynchronous(_ => { processed.TrySetResult(); - return Task.CompletedTask; + return default; }); subject.OnNext(1); @@ -532,7 +532,7 @@ public async Task WhenSubscribeSynchronousOmitsErrorAndCompletedCallbacks_ThenNu subject.OnError(new InvalidOperationException("ignored")); var second = new Subject(); - using var sub2 = second.SubscribeSynchronous(static _ => Task.CompletedTask); + using var sub2 = second.SubscribeSynchronous(static _ => default); second.OnCompleted(); } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs index e69a269..ac1284a 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SubscribeAsyncObservableTests.cs @@ -33,7 +33,7 @@ public async Task WhenSubscribeAsyncProcessesValues_ThenInOrder() x => { results.Add(x); - return Task.CompletedTask; + return default; }, static _ => { }, () => completed.TrySetResult(true)); @@ -58,7 +58,7 @@ public async Task WhenSubscribeAsyncHandlerThrows_ThenForwardsToOnError() var expected = new InvalidOperationException(HandlerFailedMessage); using var sub = subject.SubscribeSynchronous( - _ => Task.FromException(expected), + _ => ValueTask.FromException(expected), ex => faulted.TrySetResult(ex)); subject.OnNext(TriggerValue); @@ -77,7 +77,7 @@ public async Task WhenSubscribeAsyncSourceErrors_ThenForwardsToOnError() var expected = new InvalidOperationException(SourceErrorMessage); using var sub = subject.SubscribeSynchronous( - static _ => Task.CompletedTask, + static _ => default, ex => caught = ex); subject.OnError(expected); @@ -124,7 +124,7 @@ public async Task WhenSubscribeAsyncDisposed_ThenStopsProcessing() var sub = subject.SubscribeSynchronous(_ => { Interlocked.Increment(ref handlerRan); - return Task.CompletedTask; + return default; }); sub.Dispose(); @@ -148,7 +148,7 @@ public async Task WhenEventsAfterCompleted_ThenDropped() x => { values.Add(x); - return Task.CompletedTask; + return default; }, ex => caught = ex, () => completedCount++); @@ -175,7 +175,7 @@ public async Task WhenOnCompletedAfterError_ThenDropped() var expected = new InvalidOperationException("first"); using var sub = source.SubscribeSynchronous( - static _ => Task.CompletedTask, + static _ => default, ex => caught = ex, () => completedCount++); diff --git a/src/tests/ReactiveUI.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs index 0dc82ce..dc2e758 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/Operators/SynchronizeAsyncObservableTests.cs @@ -55,4 +55,27 @@ public async Task WhenOnCompletedAfterError_ThenDropped() await Assert.That(caught).IsSameReferenceAs(expected); await Assert.That(completedCount).IsEqualTo(0); } + + /// Verifies the per-emission Sync signal latches on first dispose so a second + /// dispose by the consumer is a silent no-op. + /// A representing the asynchronous test operation. + [Test] + public async Task WhenSyncSignalDisposedTwice_ThenSecondDisposeIsNoOp() + { + var source = new SyncDirectSource(); + var processed = 0; + + using var sub = source.SynchronizeAsync() + .Subscribe(t => + { + t.Sync.Dispose(); + t.Sync.Dispose(); + processed++; + }); + + source.Observer.OnNext(1); + + await Task.Delay(SettleDelayMilliseconds); + await Assert.That(processed).IsEqualTo(1); + } } diff --git a/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs b/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs index 72ad98c..f2ad692 100644 --- a/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs +++ b/src/tests/ReactiveUI.Extensions.Tests/ReactiveExtensionsTests.Misc.cs @@ -424,6 +424,21 @@ public async Task ToHotTask_ConvertsToTask() await Assert.That(await task).IsEqualTo(SampleValue42); } + /// + /// Tests ToHotValueTask converts to a hot value task that completes with the first value. + /// + /// A representing the asynchronous test operation. + [Test] + public async Task ToHotValueTask_ConvertsToValueTask() + { + var subject = new Subject(); + var task = subject.ToHotValueTask(); + + subject.OnNext(SampleValue42); + + await Assert.That(await task).IsEqualTo(SampleValue42); + } + /// /// Tests ToPropertyObservable observes property changes. ///