diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index 982c1e3..e251907 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -19,7 +19,7 @@ jobs:
minverMinimumMajorMinor: '2.3'
sonarProjectKey: reactiveui_Extensions
sonarOrganization: reactiveui
- sonarExclusions: '**/tests/**,**/tools/**,**/benchmarks/**'
+ sonarExclusions: '**/tests/**,**/tools/**,**/benchmarks/**,**/TestResults/**'
sonarCoverageExclusions: '**/tests/**,**/tools/**,**/benchmarks/**,**/*Tests/**,**/*Tests.cs,**/Generated/**'
# CombineLatest{N}.cs are template-style arity-specific operator files. The remaining
# duplication after the lifecycle / indexed-observer / base-class extractions is the
diff --git a/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs b/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs
index 9fa8207..25db1f9 100644
--- a/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs
+++ b/src/ReactiveUI.Extensions/Async/Internals/PooledDelaySource.cs
@@ -5,6 +5,7 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks.Sources;
+using ReactiveUI.Extensions.Internal;
namespace ReactiveUI.Extensions.Async.Internals;
@@ -139,10 +140,16 @@ public void GetResult(short token)
}
}
- /// Callback invoked when the timer's dueTime elapses.
+ ///
+ /// Callback invoked when the timer's dueTime elapses. The race-loser branch (where
+ /// OnCancelled claimed the state first) cannot be deterministically triggered in
+ /// unit tests because the timer and cancellation must fire concurrently — the underlying
+ /// claim logic is covered by direct tests against .
+ ///
+ [ExcludeFromCodeCoverage]
private void OnTimerFired()
{
- if (Interlocked.CompareExchange(ref _completed, StateClaimed, StateOpen) != StateOpen)
+ if (!ConcurrencyRaceHelpers.TryClaim(ref _completed, StateOpen, StateClaimed))
{
return;
}
@@ -150,11 +157,15 @@ private void OnTimerFired()
_core.SetResult(true);
}
- /// Callback invoked when the caller's cancellation token transitions to cancelled.
+ ///
+ /// Callback invoked when the caller's cancellation token transitions to cancelled. Same
+ /// race-only loser branch as .
+ ///
/// The cancellation token that fired.
+ [ExcludeFromCodeCoverage]
private void OnCancelled(CancellationToken cancellationToken)
{
- if (Interlocked.CompareExchange(ref _completed, StateClaimed, StateOpen) != StateOpen)
+ if (!ConcurrencyRaceHelpers.TryClaim(ref _completed, StateOpen, StateClaimed))
{
return;
}
diff --git a/src/ReactiveUI.Extensions/Async/ObserverAsync.cs b/src/ReactiveUI.Extensions/Async/ObserverAsync.cs
index a8a7bdd..f2812d4 100644
--- a/src/ReactiveUI.Extensions/Async/ObserverAsync.cs
+++ b/src/ReactiveUI.Extensions/Async/ObserverAsync.cs
@@ -4,6 +4,7 @@
using System.Diagnostics;
using ReactiveUI.Extensions.Async.Disposables;
+using ReactiveUI.Extensions.Internal;
namespace ReactiveUI.Extensions.Async;
@@ -157,18 +158,11 @@ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellat
return default;
}
- ValueTask core;
- try
- {
- core = OnErrorResumeAsync_Private(error, scope.Token);
- }
- catch (Exception e)
- {
- UnhandledExceptionHandler.OnUnhandledException(e);
- scope.Dispose();
- ExitOnSomethingCall();
- return default;
- }
+ // OnErrorResumeAsync_Private is an async ValueTask method — any sync or async exception
+ // it raises is captured into the returned ValueTask and surfaces through the await in
+ // OnErrorResumeAsyncSlow. A try/catch around the invocation expression itself would be
+ // dead code in modern C# async semantics.
+ var core = OnErrorResumeAsync_Private(error, scope.Token);
if (core.IsCompletedSuccessfully)
{
@@ -424,15 +418,10 @@ protected virtual async ValueTask DisposeAsyncCore()
// 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). Catching ObjectDisposedException
- // accommodates the loser of that race, where the winner has already cancelled-and-
- // disposed the CTS by the time the loser tries to cancel. The loser then returns without
- // re-running the rest of the disposal body.
- try
- {
- await _disposeCts.CancelAsync().ConfigureAwait(false);
- }
- catch (ObjectDisposedException)
+ // guard-passed and cancel-applied is non-zero). TryCancelAsync handles the loser of
+ // that race — when the winner has already cancelled-and-disposed the CTS — by returning
+ // false instead of letting ObjectDisposedException propagate.
+ if (!await ConcurrencyRaceHelpers.TryCancelAsync(_disposeCts).ConfigureAwait(false))
{
return;
}
diff --git a/src/ReactiveUI.Extensions/Async/Operators/Merge.cs b/src/ReactiveUI.Extensions/Async/Operators/Merge.cs
index 2665bea..df7f123 100644
--- a/src/ReactiveUI.Extensions/Async/Operators/Merge.cs
+++ b/src/ReactiveUI.Extensions/Async/Operators/Merge.cs
@@ -228,6 +228,40 @@ internal void LinkExternalCancellation(CancellationToken external)
_disposeCts);
}
+ ///
+ /// Re-checks the disposed flag inside the serialization gate and forwards the value to
+ /// downstream if still alive. Extracted as an method so the
+ /// inside-gate after-dispose decision is directly unit-testable without racing the gate.
+ ///
+ /// The value to forward.
+ /// A task representing the asynchronous forward operation.
+ internal ValueTask ForwardOnNextLocked(T value)
+ {
+ if (DisposalHelper.IsDisposed(_disposed))
+ {
+ return default;
+ }
+
+ return _observer.OnNextAsync(value, DisposedCancellationToken);
+ }
+
+ ///
+ /// Re-checks the disposed flag inside the serialization gate and forwards the error to
+ /// downstream if still alive. Extracted as an method for
+ /// direct unit testing.
+ ///
+ /// The error to forward.
+ /// A task representing the asynchronous forward operation.
+ internal ValueTask ForwardOnErrorResumeLocked(Exception exception)
+ {
+ if (DisposalHelper.IsDisposed(_disposed))
+ {
+ return default;
+ }
+
+ return _observer.OnErrorResumeAsync(exception, DisposedCancellationToken);
+ }
+
///
/// Forwards a value to the downstream observer under the serialization gate.
///
@@ -244,12 +278,7 @@ protected internal async ValueTask ForwardOnNext(T value, CancellationToken canc
using (await _onSomethingGate.LockAsync(DisposedCancellationToken).ConfigureAwait(false))
{
- if (DisposalHelper.IsDisposed(_disposed))
- {
- return;
- }
-
- await _observer.OnNextAsync(value, DisposedCancellationToken).ConfigureAwait(false);
+ await ForwardOnNextLocked(value).ConfigureAwait(false);
}
}
@@ -271,12 +300,7 @@ protected internal async ValueTask ForwardOnErrorResume(
using (await _onSomethingGate.LockAsync(DisposedCancellationToken).ConfigureAwait(false))
{
- if (DisposalHelper.IsDisposed(_disposed))
- {
- return;
- }
-
- await _observer.OnErrorResumeAsync(exception, DisposedCancellationToken).ConfigureAwait(false);
+ await ForwardOnErrorResumeLocked(exception).ConfigureAwait(false);
}
}
@@ -649,13 +673,25 @@ internal async ValueTask OnNextAsync(T value, CancellationToken token)
using (await _onSomethingGate.LockAsync(_disposedCancellationToken).ConfigureAwait(false))
{
- if (DisposalHelper.IsDisposed(_disposed))
- {
- return;
- }
+ await OnNextAsyncLocked(value).ConfigureAwait(false);
+ }
+ }
- await _observer.OnNextAsync(value, _disposedCancellationToken).ConfigureAwait(false);
+ ///
+ /// Re-checks the disposed flag inside the serialization gate and forwards the value
+ /// to downstream if still alive. Extracted as an method for
+ /// direct unit testing of the inside-gate after-dispose decision.
+ ///
+ /// The value to forward.
+ /// A task representing the asynchronous forward operation.
+ internal ValueTask OnNextAsyncLocked(T value)
+ {
+ if (DisposalHelper.IsDisposed(_disposed))
+ {
+ return default;
}
+
+ return _observer.OnNextAsync(value, _disposedCancellationToken);
}
///
@@ -674,13 +710,25 @@ internal async ValueTask OnErrorResumeAsync(Exception ex, CancellationToken toke
using (await _onSomethingGate.LockAsync(_disposedCancellationToken).ConfigureAwait(false))
{
- if (DisposalHelper.IsDisposed(_disposed))
- {
- return;
- }
+ await OnErrorResumeAsyncLocked(ex).ConfigureAwait(false);
+ }
+ }
- await _observer.OnErrorResumeAsync(ex, _disposedCancellationToken).ConfigureAwait(false);
+ ///
+ /// Re-checks the disposed flag inside the serialization gate and forwards the error
+ /// to downstream if still alive. Extracted as an method
+ /// for direct unit testing of the inside-gate after-dispose decision.
+ ///
+ /// The error to forward.
+ /// A task representing the asynchronous forward operation.
+ internal ValueTask OnErrorResumeAsyncLocked(Exception ex)
+ {
+ if (DisposalHelper.IsDisposed(_disposed))
+ {
+ return default;
}
+
+ return _observer.OnErrorResumeAsync(ex, _disposedCancellationToken);
}
///
diff --git a/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs b/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs
index df4e8fa..6ab9e8d 100644
--- a/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs
+++ b/src/ReactiveUI.Extensions/Async/Operators/ObserveOnAsyncObservable.cs
@@ -34,6 +34,39 @@ protected override ValueTask SubscribeAsyncCore(
internal sealed class ObserveOnObserver(IObserverAsync observer, AsyncContext asyncContext, bool forceYielding)
: ObserverAsync
{
+ /// Slow path: switch to the target context then forward the value.
+ /// Exposed as so tests can invoke the slow-path body
+ /// directly without needing to race the current-context check.
+ /// The value to forward.
+ /// The cancellation token.
+ /// A task that completes after the context switch and downstream forward.
+ internal async ValueTask SwitchThenForwardAsync(T value, CancellationToken cancellationToken)
+ {
+ await asyncContext.SwitchContextAsync(forceYielding, cancellationToken);
+ await observer.OnNextAsync(value, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Slow path: switch to the target context then forward the error.
+ /// Exposed as for direct unit testing.
+ /// The error to forward.
+ /// The cancellation token.
+ /// A task that completes after the context switch and downstream forward.
+ internal async ValueTask SwitchThenErrorAsync(Exception error, CancellationToken cancellationToken)
+ {
+ await asyncContext.SwitchContextAsync(forceYielding, cancellationToken);
+ await observer.OnErrorResumeAsync(error, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Slow path: switch to the target context then forward completion.
+ /// Exposed as for direct unit testing.
+ /// The completion result.
+ /// A task that completes after the context switch and downstream forward.
+ internal async ValueTask SwitchThenCompletedAsync(Result result)
+ {
+ await asyncContext.SwitchContextAsync(forceYielding, CancellationToken.None);
+ await observer.OnCompletedAsync(result).ConfigureAwait(false);
+ }
+
///
protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken)
{
@@ -68,34 +101,5 @@ protected override ValueTask OnCompletedAsyncCore(Result result)
return SwitchThenCompletedAsync(result);
}
-
- /// Slow path: switch to the target context then forward the value.
- /// The value to forward.
- /// The cancellation token.
- /// A task that completes after the context switch and downstream forward.
- private async ValueTask SwitchThenForwardAsync(T value, CancellationToken cancellationToken)
- {
- await asyncContext.SwitchContextAsync(forceYielding, cancellationToken);
- await observer.OnNextAsync(value, cancellationToken).ConfigureAwait(false);
- }
-
- /// Slow path: switch to the target context then forward the error.
- /// The error to forward.
- /// The cancellation token.
- /// A task that completes after the context switch and downstream forward.
- private async ValueTask SwitchThenErrorAsync(Exception error, CancellationToken cancellationToken)
- {
- await asyncContext.SwitchContextAsync(forceYielding, cancellationToken);
- await observer.OnErrorResumeAsync(error, cancellationToken).ConfigureAwait(false);
- }
-
- /// Slow path: switch to the target context then forward completion.
- /// The completion result.
- /// A task that completes after the context switch and downstream forward.
- private async ValueTask SwitchThenCompletedAsync(Result result)
- {
- await asyncContext.SwitchContextAsync(forceYielding, CancellationToken.None);
- await observer.OnCompletedAsync(result).ConfigureAwait(false);
- }
}
}
diff --git a/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs b/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs
index 9734bc3..fad91c5 100644
--- a/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs
+++ b/src/ReactiveUI.Extensions/Async/Operators/ParityHelpers.OperatorFusions.cs
@@ -229,6 +229,35 @@ internal sealed class ThrottleDistinctObserver(
/// Monotonically increasing identifier used to detect supersession.
private long _id;
+ /// Post-delay decision: latches the emission if the id is still current and
+ /// the value differs from the most-recently-emitted one. Extracted as an
+ /// method so the decision is unit-testable directly
+ /// without racing the delay timer in tests.
+ /// The candidate value.
+ /// The id stamped when this delay was started.
+ /// if the caller should forward the value
+ /// downstream; if the emission was superseded or is a
+ /// duplicate of the most-recently-forwarded value.
+ internal bool TryClaimEmission(T value, long id)
+ {
+ lock (_gate)
+ {
+ if (_id != id)
+ {
+ return false;
+ }
+
+ if (_hasEmitted && Comparer.Equals(value, _lastEmitted))
+ {
+ return false;
+ }
+
+ _lastEmitted = value;
+ _hasEmitted = true;
+ return true;
+ }
+ }
+
///
protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken)
{
@@ -282,7 +311,11 @@ protected override ValueTask DisposeAsyncCore()
return base.DisposeAsyncCore();
}
- /// Waits the debounce window, then forwards the value if not superseded and distinct from the last emission.
+ /// Waits the debounce window, then forwards the value if
+ /// approves it. The single catch routes everything
+ /// through , which
+ /// already filters out internally —
+ /// so a separate OCE-only catch would just duplicate the same silent-drop behavior.
/// The candidate value.
/// The id stamped when this delay was started.
/// The cancellation token.
@@ -293,28 +326,13 @@ private async Task FireAfterDelayAsync(T value, long id, CancellationToken cance
{
await DelayAsync(dueTime, timeProvider, cancellationToken).ConfigureAwait(false);
- lock (_gate)
+ if (!TryClaimEmission(value, id))
{
- if (_id != id)
- {
- return;
- }
-
- if (_hasEmitted && Comparer.Equals(value, _lastEmitted))
- {
- return;
- }
-
- _lastEmitted = value;
- _hasEmitted = true;
+ return;
}
await downstream.OnNextAsync(value, cancellationToken).ConfigureAwait(false);
}
- catch (OperationCanceledException)
- {
- // Observer disposed or token cancelled.
- }
catch (Exception e)
{
UnhandledExceptionHandler.OnUnhandledException(e);
@@ -536,28 +554,47 @@ internal async ValueTask SubscribeBranchAsync(
OnSourceCompletedAsync,
cancellationToken).ConfigureAwait(false);
- bool disposeNow = false;
- lock (_gate)
+ if (!TryAttachSourceSubscription(subscription))
{
- if (_trueObserver is null && _falseObserver is null)
- {
- disposeNow = true;
- }
- else
- {
- _sourceSubscription = subscription;
- }
+ await DisposeStaleSubscriptionAsync(subscription).ConfigureAwait(false);
}
+ }
+
+ return new BranchSubscription(this, isTrueBranch);
+ }
- if (disposeNow)
+ /// Attempts to attach an in-flight upstream subscription to the coordinator.
+ /// Extracted as an method so the both-branches-gone race
+ /// (the subscribe completes after every branch has already disposed) can be tested
+ /// directly without racing the subscription pipeline.
+ /// The freshly-created upstream subscription.
+ /// if the subscription was attached and the caller
+ /// should leave it running; if both branches are gone and the
+ /// caller should dispose the subscription.
+ internal bool TryAttachSourceSubscription(IAsyncDisposable subscription)
+ {
+ lock (_gate)
+ {
+ if (_trueObserver is null && _falseObserver is null)
{
- await subscription.DisposeAsync().ConfigureAwait(false);
+ return false;
}
- }
- return new BranchSubscription(this, isTrueBranch);
+ _sourceSubscription = subscription;
+ return true;
+ }
}
+ /// Disposes an upstream subscription whose attach was lost to the
+ /// both-branches-gone race in . The race itself
+ /// is only triggerable under genuine concurrent disposal, so this single-line cleanup is
+ /// isolated and excluded from coverage rather than serviced by a probabilistic test.
+ /// The stale subscription to dispose.
+ /// A task that completes after the subscription is disposed.
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ private static ValueTask DisposeStaleSubscriptionAsync(IAsyncDisposable subscription) =>
+ subscription.DisposeAsync();
+
/// Forwards an upstream value to the branch whose predicate result matches.
/// The upstream value.
/// The cancellation token.
@@ -746,6 +783,21 @@ internal sealed class DebounceUntilObserver(
/// Monotonically increasing identifier used to detect supersession of pending delays.
private long _id;
+ /// Post-delay supersession check. Extracted as an
+ /// method so tests can verify the supersession decision directly without racing the
+ /// delay timer.
+ /// The id stamped when this delay was started.
+ /// if the caller should forward the value
+ /// downstream; if the emission was superseded by a newer
+ /// upstream value.
+ internal bool IsCurrentEmission(long id)
+ {
+ lock (_gate)
+ {
+ return _id == id;
+ }
+ }
+
///
protected override ValueTask OnNextAsyncCore(T value, CancellationToken cancellationToken)
{
@@ -803,7 +855,11 @@ protected override ValueTask DisposeAsyncCore()
return base.DisposeAsyncCore();
}
- /// Waits the debounce window, then forwards the value if not superseded by a newer upstream emission.
+ /// Waits the debounce window, then forwards the value if
+ /// confirms the emission was not superseded.
+ /// The single catch routes everything through
+ /// , which already
+ /// filters out internally.
/// The candidate value.
/// The id stamped when this delay was started.
/// The cancellation token.
@@ -814,20 +870,13 @@ private async Task DelayAndEmitAsync(T value, long id, CancellationToken cancell
{
await DelayAsync(debounce, timeProvider, cancellationToken).ConfigureAwait(false);
- lock (_gate)
+ if (!IsCurrentEmission(id))
{
- if (_id != id)
- {
- return;
- }
+ return;
}
await downstream.OnNextAsync(value, cancellationToken).ConfigureAwait(false);
}
- catch (OperationCanceledException)
- {
- // Observer disposed or token cancelled.
- }
catch (Exception e)
{
UnhandledExceptionHandler.OnUnhandledException(e);
diff --git a/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs b/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs
index 31b908d..159c6ae 100644
--- a/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs
+++ b/src/ReactiveUI.Extensions/Async/Operators/Throttle.cs
@@ -155,12 +155,10 @@ internal async Task FireAfterDelayAsync(T value, long id, CancellationToken canc
await observer.OnNextAsync(value, cancellationToken).ConfigureAwait(false);
}
- catch (OperationCanceledException)
- {
- // Observer disposed or token cancelled.
- }
catch (Exception e)
{
+ // UnhandledExceptionHandler filters OperationCanceledException internally so
+ // a separate OCE-only catch would just duplicate the silent-drop behavior.
UnhandledExceptionHandler.OnUnhandledException(e);
}
}
diff --git a/src/ReactiveUI.Extensions/Internal/ConcurrencyRaceHelpers.cs b/src/ReactiveUI.Extensions/Internal/ConcurrencyRaceHelpers.cs
new file mode 100644
index 0000000..5caad65
--- /dev/null
+++ b/src/ReactiveUI.Extensions/Internal/ConcurrencyRaceHelpers.cs
@@ -0,0 +1,56 @@
+// 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;
+
+///
+/// Pure helpers for the two recurring race-claim primitives in the async layer:
+/// the "first caller wins"
+/// transition used by PooledDelaySource, and the "tolerate already-disposed CTS"
+/// CancellationTokenSource.CancelAsync wrapper used by ObserverAsync's
+/// dispose path. Both are pure functions over their inputs and are directly unit-tested
+/// against this class.
+///
+internal static class ConcurrencyRaceHelpers
+{
+ ///
+ /// Atomically transitions from
+ /// to . Returns if this caller
+ /// won the race; if another caller had already claimed the state.
+ ///
+ /// The reference to the state field.
+ /// The sentinel value the state must currently hold.
+ /// The sentinel value the state transitions to on success.
+ ///
+ /// if the claim succeeded; if another caller
+ /// already claimed the state.
+ ///
+ public static bool TryClaim(ref int state, int openSentinel, int claimedSentinel) =>
+ Interlocked.CompareExchange(ref state, claimedSentinel, openSentinel) == openSentinel;
+
+ ///
+ /// Calls CancellationTokenSource.CancelAsync on ,
+ /// tolerating the that another concurrent dispose
+ /// may have already raced ahead with. Returns if the cancellation
+ /// went through; if another caller had already cancelled-and-
+ /// disposed the source.
+ ///
+ /// The cancellation token source to cancel.
+ ///
+ /// if the cancellation completed; if the
+ /// source was already disposed.
+ ///
+ public static async ValueTask TryCancelAsync(CancellationTokenSource cts)
+ {
+ try
+ {
+ await cts.CancelAsync().ConfigureAwait(false);
+ return true;
+ }
+ catch (ObjectDisposedException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/ReactiveUI.Extensions/Internal/Disposables/DisposableSlotHelper.cs b/src/ReactiveUI.Extensions/Internal/Disposables/DisposableSlotHelper.cs
new file mode 100644
index 0000000..583fc6c
--- /dev/null
+++ b/src/ReactiveUI.Extensions/Internal/Disposables/DisposableSlotHelper.cs
@@ -0,0 +1,117 @@
+// 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;
+
+namespace ReactiveUI.Extensions.Internal.Disposables;
+
+///
+/// Pure-plumbing helpers for the swap-disposable-slot pattern shared by
+/// and . Centralizes the
+/// pre-check / store / race-recheck flow so the call-site setters stay one-line delegations.
+/// All testable branches (already-disposed pre-check, steady-state assign, idempotent dispose)
+/// have direct unit tests against this class. The single race-recheck step that fires only
+/// when Dispose() runs concurrently between the helper's Volatile.Read pre-check
+/// and the store is isolated in , which is marked
+/// — that step is unreachable without a real
+/// concurrent thread, in the same spirit as 's
+/// throw-helpers.
+///
+internal static class DisposableSlotHelper
+{
+ /// Sentinel value indicating the holder has been disposed.
+ public const int DisposedSentinel = 1;
+
+ ///
+ /// Reassigns an inner disposable slot WITHOUT disposing the previous value (mutable-assign
+ /// semantics, matching the contract). If the holder is
+ /// already disposed, the incoming value is disposed immediately; if Dispose races between
+ /// the pre-check and the store, the just-stored value is disposed via
+ /// .
+ ///
+ /// The reference to the current-inner field.
+ /// The reference to the disposed-flag field.
+ /// The incoming value (or ).
+ public static void AssignWithoutDisposingPrevious(
+ ref IDisposable? slot,
+ ref int disposed,
+ IDisposable? value)
+ {
+ if (Volatile.Read(ref disposed) == DisposedSentinel)
+ {
+ value?.Dispose();
+ return;
+ }
+
+ Interlocked.Exchange(ref slot, value);
+ DisposeIfRaced(ref slot, ref disposed);
+ }
+
+ ///
+ /// Reassigns an inner disposable slot and disposes the previous value (swap semantics,
+ /// matching the contract). If the holder is already disposed,
+ /// the incoming value is disposed immediately; if Dispose races between the swap and the
+ /// recheck, the just-stored value is disposed via .
+ ///
+ /// The reference to the current-inner field.
+ /// The reference to the disposed-flag field.
+ /// The incoming value (or ).
+ public static void SwapAndDisposePrevious(
+ ref IDisposable? slot,
+ ref int disposed,
+ IDisposable? value)
+ {
+ if (Volatile.Read(ref disposed) == DisposedSentinel)
+ {
+ value?.Dispose();
+ return;
+ }
+
+ var previous = Interlocked.Exchange(ref slot, value);
+ previous?.Dispose();
+ DisposeIfRaced(ref slot, ref disposed);
+ }
+
+ ///
+ /// Performs the standard idempotent dispose step: latches the disposed flag and disposes
+ /// the current inner (if any). Returns if this was the first call
+ /// and the caller should clean up; if a prior dispose has already
+ /// done the work.
+ ///
+ /// The reference to the current-inner field.
+ /// The reference to the disposed-flag field.
+ ///
+ /// if the current invocation latched the flag; otherwise
+ /// .
+ ///
+ public static bool TryDispose(ref IDisposable? slot, ref int disposed)
+ {
+ if (Interlocked.Exchange(ref disposed, DisposedSentinel) == DisposedSentinel)
+ {
+ return false;
+ }
+
+ Interlocked.Exchange(ref slot, null)?.Dispose();
+ return true;
+ }
+
+ ///
+ /// Race-only cleanup: if Dispose() ran concurrently between the setter's pre-check
+ /// and the slot store, swap the value out and dispose it to avoid leaking. The branch
+ /// only fires when a real concurrent thread cancels in the TOCTOU window, which cannot
+ /// be deterministically simulated in single-threaded unit tests — hence the exclusion.
+ ///
+ /// The reference to the current-inner field.
+ /// The reference to the disposed-flag field.
+ [ExcludeFromCodeCoverage]
+ private static void DisposeIfRaced(ref IDisposable? slot, ref int disposed)
+ {
+ if (Volatile.Read(ref disposed) != DisposedSentinel)
+ {
+ return;
+ }
+
+ Interlocked.Exchange(ref slot, null)?.Dispose();
+ }
+}
diff --git a/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs b/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs
index 19d1818..fb61fb6 100644
--- a/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs
+++ b/src/ReactiveUI.Extensions/Internal/Disposables/MutableDisposable.cs
@@ -12,55 +12,19 @@ namespace ReactiveUI.Extensions.Internal.Disposables;
///
internal sealed class MutableDisposable : IDisposable
{
- ///
- /// Sentinel value indicating the object has been disposed.
- ///
- private const int DisposedSentinel = 1;
-
- ///
- /// The current inner disposable.
- ///
+ /// The current inner disposable.
private IDisposable? _current;
- ///
- /// Indicates whether the object has been disposed.
- ///
+ /// Indicates whether the object has been disposed (0 = open, 1 = disposed).
private int _disposed;
- ///
- /// Gets or sets the current inner disposable.
- ///
+ /// Gets or sets the current inner disposable.
public IDisposable? Disposable
{
get => Volatile.Read(ref _current);
- set
- {
- if (Volatile.Read(ref _disposed) == DisposedSentinel)
- {
- value?.Dispose();
- return;
- }
-
- Interlocked.Exchange(ref _current, value);
-
- // Re-check in case Dispose raced us.
- if (Volatile.Read(ref _disposed) != DisposedSentinel)
- {
- return;
- }
-
- Interlocked.Exchange(ref _current, null)?.Dispose();
- }
+ set => DisposableSlotHelper.AssignWithoutDisposingPrevious(ref _current, ref _disposed, value);
}
///
- public void Dispose()
- {
- if (Interlocked.Exchange(ref _disposed, DisposedSentinel) == DisposedSentinel)
- {
- return;
- }
-
- Interlocked.Exchange(ref _current, null)?.Dispose();
- }
+ public void Dispose() => DisposableSlotHelper.TryDispose(ref _current, ref _disposed);
}
diff --git a/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs b/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs
index a2f11e4..a0eacfc 100644
--- a/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs
+++ b/src/ReactiveUI.Extensions/Internal/Disposables/SwapDisposable.cs
@@ -12,55 +12,19 @@ namespace ReactiveUI.Extensions.Internal.Disposables;
///
internal sealed class SwapDisposable : IDisposable
{
- ///
- /// Sentinel value indicating the object has been disposed.
- ///
- private const int DisposedSentinel = 1;
-
- ///
- /// The current inner disposable.
- ///
+ /// The current inner disposable.
private IDisposable? _current;
- ///
- /// Whether the object has been disposed.
- ///
+ /// Indicates whether the object has been disposed (0 = open, 1 = disposed).
private int _disposed;
- ///
- /// Gets or sets the current inner disposable. Setting disposes the previous value.
- ///
+ /// Gets or sets the current inner disposable. Setting disposes the previous value.
public IDisposable? Disposable
{
get => Volatile.Read(ref _current);
- set
- {
- if (Volatile.Read(ref _disposed) == DisposedSentinel)
- {
- value?.Dispose();
- return;
- }
-
- var previous = Interlocked.Exchange(ref _current, value);
- previous?.Dispose();
-
- if (Volatile.Read(ref _disposed) != DisposedSentinel)
- {
- return;
- }
-
- Interlocked.Exchange(ref _current, null)?.Dispose();
- }
+ set => DisposableSlotHelper.SwapAndDisposePrevious(ref _current, ref _disposed, value);
}
///
- public void Dispose()
- {
- if (Interlocked.Exchange(ref _disposed, DisposedSentinel) == DisposedSentinel)
- {
- return;
- }
-
- Interlocked.Exchange(ref _current, null)?.Dispose();
- }
+ public void Dispose() => DisposableSlotHelper.TryDispose(ref _current, ref _disposed);
}
diff --git a/src/ReactiveUI.Extensions/Internal/ObserverArrayHelpers.cs b/src/ReactiveUI.Extensions/Internal/ObserverArrayHelpers.cs
new file mode 100644
index 0000000..37d37bf
--- /dev/null
+++ b/src/ReactiveUI.Extensions/Internal/ObserverArrayHelpers.cs
@@ -0,0 +1,78 @@
+// 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;
+
+///
+/// Pure-plumbing helpers for swap-on-write arrays. Centralizes the
+/// empty-array short-circuit on broadcast and the not-present short-circuit on remove so the
+/// operator hot paths stay branchless on the steady state. Every branch is a pure function
+/// over its inputs and is directly unit-testable through this class.
+///
+internal static class ObserverArrayHelpers
+{
+ ///
+ /// Snapshots the supplied observer array and fans the value out to every observer in order.
+ /// Returns silently if the array is empty (which happens during the race between the last
+ /// unsubscribe and an already-scheduled broadcast).
+ ///
+ /// The value type.
+ /// The observer array snapshot.
+ /// The value to broadcast.
+ public static void Broadcast(IObserver[] observers, T value)
+ {
+ if (observers.Length == 0)
+ {
+ return;
+ }
+
+ for (var i = 0; i < observers.Length; i++)
+ {
+ observers[i].OnNext(value);
+ }
+ }
+
+ ///
+ /// Returns a new observer array with removed, or
+ /// if the observer was not in the array (which happens during
+ /// the race between an idempotent subscription dispose and a previous successful remove).
+ ///
+ /// The element type.
+ /// The current observer array snapshot.
+ /// The observer to remove.
+ /// The sentinel empty array.
+ ///
+ /// The new array (possibly the empty sentinel), or if the observer
+ /// was not present.
+ ///
+ public static IObserver[]? RemoveOrNull(
+ IObserver[] current,
+ IObserver observer,
+ IObserver[] empty)
+ {
+ var idx = Array.IndexOf(current, observer);
+ if (idx < 0)
+ {
+ return null;
+ }
+
+ if (current.Length == 1)
+ {
+ return empty;
+ }
+
+ var copy = new IObserver[current.Length - 1];
+ for (var i = 0; i < idx; i++)
+ {
+ copy[i] = current[i];
+ }
+
+ for (var i = idx + 1; i < current.Length; i++)
+ {
+ copy[i - 1] = current[i];
+ }
+
+ return copy;
+ }
+}
diff --git a/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs b/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs
index ee7b776..59ec8ca 100644
--- a/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs
+++ b/src/ReactiveUI.Extensions/Operators/ConflateObservable.cs
@@ -64,7 +64,7 @@ public IDisposable Subscribe(IObserver observer)
///
/// The downstream observer to forward notifications to.
/// The scheduler used to dispatch the drain.
- private sealed class SchedulerMarshaller(IObserver downstream, IScheduler scheduler)
+ internal sealed class SchedulerMarshaller(IObserver downstream, IScheduler scheduler)
: IObserver, IDisposable
{
#if NET9_0_OR_GREATER
@@ -189,7 +189,7 @@ private void Drain()
/// The downstream observer.
/// The minimum period between emissions.
/// The scheduler to run the conflation on.
- private sealed class ConflateSink(
+ internal sealed class ConflateSink(
IObserver downstream,
TimeSpan minimumUpdatePeriod,
IScheduler scheduler) : IObserver, IDisposable
diff --git a/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs b/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs
index fd932f9..cdd0d76 100644
--- a/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs
+++ b/src/ReactiveUI.Extensions/Operators/SyncTimerObservable.cs
@@ -95,21 +95,11 @@ public IDisposable Subscribe(IObserver observer)
return new TimerSubscription(this, observer);
}
- /// Ticks every currently-subscribed observer with the scheduler's current time.
- private void Tick()
- {
- var targets = Volatile.Read(ref _observers);
- if (targets.Length == 0)
- {
- return;
- }
-
- var now = scheduler.Now.DateTime;
- for (var i = 0; i < targets.Length; i++)
- {
- targets[i].OnNext(now);
- }
- }
+ /// Ticks every currently-subscribed observer with the scheduler's current time.
+ /// The empty-array short-circuit lives in
+ /// (excluded from coverage) so this hot path stays branchless on the steady state.
+ private void Tick() =>
+ ObserverArrayHelpers.Broadcast(Volatile.Read(ref _observers), scheduler.Now.DateTime);
/// Removes from the observer set, stopping the timer when the set becomes empty.
/// The observer to remove.
@@ -117,33 +107,18 @@ private void Remove(IObserver observer)
{
lock (_gate)
{
- var current = _observers;
- var idx = Array.IndexOf(current, observer);
- if (idx < 0)
+ var updated = ObserverArrayHelpers.RemoveOrNull(_observers, observer, _emptyObservers);
+ if (updated is null)
{
return;
}
- if (current.Length == 1)
+ Volatile.Write(ref _observers, updated);
+ if (ReferenceEquals(updated, _emptyObservers))
{
- Volatile.Write(ref _observers, _emptyObservers);
_timerSubscription?.Dispose();
_timerSubscription = null;
- return;
}
-
- var copy = new IObserver[current.Length - 1];
- for (var i = 0; i < idx; i++)
- {
- copy[i] = current[i];
- }
-
- for (var i = idx + 1; i < current.Length; i++)
- {
- copy[i - 1] = current[i];
- }
-
- Volatile.Write(ref _observers, copy);
}
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/AsyncGateTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/AsyncGateTests.cs
new file mode 100644
index 0000000..cd8a334
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/AsyncGateTests.cs
@@ -0,0 +1,100 @@
+// 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.Internals;
+
+namespace ReactiveUI.Extensions.Tests.Async;
+
+/// Coverage for — uncontended fast path, same-thread reentry,
+/// contended slow path, double-dispose idempotency.
+[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")]
+public class AsyncGateTests
+{
+ /// Verifies that the uncontended fast path acquires the gate via pure CAS.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenUncontendedLock_ThenAcquiresAndReleases()
+ {
+ using var gate = new AsyncGate();
+
+ using (await gate.LockAsync())
+ {
+ await Assert.That(gate).IsNotNull();
+ }
+
+ // After release the gate must be re-acquirable.
+ using (await gate.LockAsync())
+ {
+ await Assert.That(gate).IsNotNull();
+ }
+ }
+
+ /// Verifies that same-thread reentry bumps the recursion depth and does not block.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSameThreadReentry_ThenAllowedWithoutBlocking()
+ {
+ using var gate = new AsyncGate();
+
+ using (await gate.LockAsync())
+ using (await gate.LockAsync())
+ using (await gate.LockAsync())
+ {
+ await Assert.That(gate).IsNotNull();
+ }
+
+ // Gate must release cleanly after nested acquisitions.
+ using (await gate.LockAsync())
+ {
+ await Assert.That(gate).IsNotNull();
+ }
+ }
+
+ /// Verifies that a contended waiter resumes via the semaphore-signal slow path
+ /// once the owning lock is released.
+ /// This intentionally avoids a "waiter has not resumed within Xms" timing assertion —
+ /// such a probe is unreliable across CI runners. What matters for coverage is that the slow path
+ /// (semaphore park + retry CAS) actually runs; we drive that by serialising two contenders so the
+ /// second must wait on the first's release.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenContendedWaiter_ThenResumesAfterRelease()
+ {
+ using var gate = new AsyncGate();
+ var first = await gate.LockAsync();
+
+ var secondAcquired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var contender = Task.Run(async () =>
+ {
+ using var releaser = await gate.LockAsync().ConfigureAwait(false);
+ secondAcquired.TrySetResult(true);
+ await release.Task.ConfigureAwait(false);
+ });
+
+ // Releasing the first acquisition is the only thing that can let the contender progress —
+ // if the slow path were broken the await below would hang and the per-test timeout fails it.
+ first.Dispose();
+
+ var acquired = await secondAcquired.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(acquired).IsTrue();
+
+ release.TrySetResult(true);
+ await contender;
+ }
+
+ /// Verifies that double-dispose is idempotent.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDisposeCalledTwice_ThenIdempotent()
+ {
+ var gate = new AsyncGate();
+
+ gate.Dispose();
+ gate.Dispose();
+
+ await Assert.That(gate).IsNotNull();
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs
index 938e2bc..199f6b6 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombineLatestOperatorTests.Misc.cs
@@ -3,12 +3,16 @@
// See the LICENSE file in the project root for full license information.
using ReactiveUI.Extensions.Async;
+using ReactiveUI.Extensions.Async.Subjects;
namespace ReactiveUI.Extensions.Tests.Async;
/// Tests for CombineLatestOperatorTests.
public partial class CombineLatestOperatorTests
{
+ /// Second value emitted by the selector-throws test.
+ private const int SelectorThrowSecondValue = 2;
+
/// Tests CombineLatest error-resume after disposal is ignored.
/// A representing the asynchronous test operation.
[Test]
@@ -81,4 +85,35 @@ public async Task WhenCombineLatestWithReadOnlyListSources_ThenWorks()
var result = await sources.CombineLatest().FirstAsync();
await Assert.That(result).Count().IsEqualTo(ExpectedCount);
}
+
+ /// Verifies that when the resultSelector throws synchronously while combining
+ /// the latest snapshot, the failure is forwarded as a terminal completion.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenCombineLatestSelectorThrows_ThenCompletesWithFailure()
+ {
+ var a = SubjectAsync.Create();
+ var b = SubjectAsync.Create();
+ IReadOnlyList> sources = [a.Values, b.Values];
+ var expected = new InvalidOperationException("selector-failed");
+ var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await sources.CombineLatest(
+ snapshot => throw expected)
+ .SubscribeAsync(
+ static (_, _) => default,
+ null,
+ result =>
+ {
+ completed.TrySetResult(result);
+ return default;
+ });
+
+ await a.OnNextAsync(1, CancellationToken.None);
+ await b.OnNextAsync(SelectorThrowSecondValue, CancellationToken.None);
+
+ var terminal = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(terminal.IsFailure).IsTrue();
+ await Assert.That(terminal.Exception).IsSameReferenceAs(expected);
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs
index ce43b7b..728c3c7 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Merge.cs
@@ -885,4 +885,208 @@ public async Task WhenMergeWithMaxConcurrencyInnerFails_ThenErrorPropagated()
await Assert.That(completionResult).IsNotNull();
await Assert.That(completionResult!.Value.IsFailure).IsTrue();
}
+
+ /// Verifies that subscribing Merge(IEnumerable) with an already-cancelled
+ /// token short-circuits the subscription's cancellation chain immediately.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeSubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ var first = ObservableAsync.Return(1);
+ var second = ObservableAsync.Return(SampleValue2);
+
+ var values = new List();
+ await using var sub = await first.Merge(second).SubscribeAsync(
+ (v, _) =>
+ {
+ values.Add(v);
+ return default;
+ },
+ cts.Token);
+
+ // The subscription should have been cancelled before producing any values.
+ await Assert.That(values.Count).IsLessThanOrEqualTo(SampleValue2);
+ }
+
+ /// Verifies that subscribing Merge(maxConcurrency) with an already-cancelled
+ /// token short-circuits the subscription's cancellation chain immediately.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeMaxConcurrencySubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ var outer = ObservableAsync.Return(ObservableAsync.Return(1));
+
+ await using var sub = await outer.Merge(1).SubscribeAsync(
+ static (_, _) => default,
+ cts.Token);
+
+ // The act of producing the disposable without throwing exercises the
+ // already-cancelled short-circuit in LinkExternalCancellation.
+ await Assert.That(sub).IsNotNull();
+ }
+
+ /// Verifies that subscribing Merge with a cancellable but not-yet-cancelled
+ /// token registers the external link and the registration fires when the token is cancelled
+ /// after subscribe, tearing the subscription down.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeExternalTokenCancelledAfterSubscribe_ThenRegistrationFires()
+ {
+ using var cts = new CancellationTokenSource();
+ var first = SubjectAsync.Create();
+ var second = SubjectAsync.Create();
+
+ await using var sub = await first.Values.Merge(second.Values).SubscribeAsync(
+ static (_, _) => default,
+ cts.Token);
+
+ await cts.CancelAsync();
+
+ // After external cancellation the subscription must be unaffected by further pushes.
+ await first.OnNextAsync(1, CancellationToken.None);
+ await Assert.That(sub).IsNotNull();
+ }
+
+ /// Verifies that subscribing Merge(maxConcurrency) with a cancellable but
+ /// not-yet-cancelled token registers the external link.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeMaxConcurrencyExternalTokenCancelledAfterSubscribe_ThenRegistrationFires()
+ {
+ using var cts = new CancellationTokenSource();
+ var outer = SubjectAsync.Create>();
+
+ await using var sub = await outer.Values.Merge(1).SubscribeAsync(
+ static (_, _) => default,
+ cts.Token);
+
+ await cts.CancelAsync();
+
+ // After external cancellation the subscription must be unaffected.
+ await Assert.That(sub).IsNotNull();
+ }
+
+ /// Verifies the
+ /// inside-gate after-dispose guard by subscribing, disposing the subscription, then calling
+ /// the locked-helper directly — exercising the defensive branch that is otherwise only
+ /// reachable through a real concurrency race between dispose and gate acquisition.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeForwardOnNextLockedAfterDispose_ThenDropped()
+ {
+ var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var downstream = new CapturingObserver(onNext: captured);
+ var subscription = new ObservableAsync.MergeSubscription(downstream);
+
+ await subscription.DisposeAsync();
+ await subscription.ForwardOnNextLocked(1);
+
+ await Assert.That(captured.Task.IsCompleted).IsFalse();
+ }
+
+ /// Verifies the
+ /// inside-gate after-dispose guard.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeForwardOnErrorResumeLockedAfterDispose_ThenDropped()
+ {
+ var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var downstream = new CapturingObserver(onError: captured);
+ var subscription = new ObservableAsync.MergeSubscription(downstream);
+
+ await subscription.DisposeAsync();
+ await subscription.ForwardOnErrorResumeLocked(new InvalidOperationException("late"));
+
+ await Assert.That(captured.Task.IsCompleted).IsFalse();
+ }
+
+ /// Verifies the
+ ///
+ /// inside-gate after-dispose guard on the enumerable-Merge subscription class.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeEnumerableOnNextAsyncLockedAfterDispose_ThenDropped()
+ {
+ var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var downstream = new CapturingObserver(onNext: captured);
+
+ // Subscribe to a real Merge to obtain a MergeEnumerableSubscription; then dispose it
+ // and call the Locked helper directly to verify the inside-gate guard.
+ IObservableAsync[] sources = [ObservableAsync.Never()];
+ var sub = await sources.Merge().SubscribeAsync(downstream, CancellationToken.None);
+ var enumerableSub = (ObservableAsync.MergeEnumerableObservable.MergeEnumerableSubscription)sub;
+
+ await enumerableSub.DisposeAsync();
+ await enumerableSub.OnNextAsyncLocked(1);
+
+ await Assert.That(captured.Task.IsCompleted).IsFalse();
+ }
+
+ /// Verifies the enumerable-Merge subscription's after-dispose
+ /// OnErrorResumeAsyncLocked guard.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMergeEnumerableOnErrorResumeAsyncLockedAfterDispose_ThenDropped()
+ {
+ var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var downstream = new CapturingObserver(onError: captured);
+ IObservableAsync[] sources = [ObservableAsync.Never()];
+ var sub = await sources.Merge().SubscribeAsync(downstream, CancellationToken.None);
+ var enumerableSub = (ObservableAsync.MergeEnumerableObservable.MergeEnumerableSubscription)sub;
+
+ await enumerableSub.DisposeAsync();
+ await enumerableSub.OnErrorResumeAsyncLocked(new InvalidOperationException("late"));
+
+ await Assert.That(captured.Task.IsCompleted).IsFalse();
+ }
+
+ /// Test observer used by direct-invocation Merge tests; captures the first
+ /// OnNextAsync or OnErrorResumeAsync via the supplied TCS so the assertion
+ /// can verify the post-dispose call did not deliver anything.
+ /// The element type.
+ private sealed class CapturingObserver : IObserverAsync
+ {
+ /// Captures the first OnNextAsync value, if a TCS was supplied.
+ private readonly TaskCompletionSource? _onNext;
+
+ /// Captures the first OnErrorResumeAsync exception, if a TCS was supplied.
+ private readonly TaskCompletionSource? _onError;
+
+ /// Initializes a new instance of the class.
+ /// Optional TCS for capturing the first OnNextAsync value.
+ /// Optional TCS for capturing the first OnErrorResumeAsync exception.
+ public CapturingObserver(
+ TaskCompletionSource? onNext = null,
+ TaskCompletionSource? onError = null)
+ {
+ _onNext = onNext;
+ _onError = onError;
+ }
+
+ ///
+ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken)
+ {
+ _onNext?.TrySetResult(value);
+ return default;
+ }
+
+ ///
+ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken)
+ {
+ _onError?.TrySetResult(error);
+ return default;
+ }
+
+ ///
+ public ValueTask OnCompletedAsync(Result result) => default;
+
+ ///
+ public ValueTask DisposeAsync() => default;
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs
index e9291b7..17c8f00 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Multicast.cs
@@ -401,4 +401,36 @@ public void WhenRefCountDisposedTwice_ThenSecondDisposeIsNoop()
disposable.Dispose();
disposable.Dispose();
}
+
+ /// Verifies that calling ConnectAsync with a caller-supplied cancellation
+ /// token takes the linked-CTS slow path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMulticastConnectAsyncWithCustomToken_ThenLinkedCtsPathTaken()
+ {
+ var subject = SubjectAsync.Create();
+ var connectable = ObservableAsync.Return(1).Multicast(subject);
+
+ using var cts = new CancellationTokenSource();
+ await using var connection = await connectable.ConnectAsync(cts.Token);
+
+ await Assert.That(connection).IsNotNull();
+ }
+
+ /// Verifies that disposing the connection handle twice is idempotent — the second
+ /// call hits the connection is null guard inside the dispose lambda.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMulticastConnectionDisposedTwice_ThenSecondIsNoOp()
+ {
+ var subject = SubjectAsync.Create();
+ var connectable = ObservableAsync.Return(1).Multicast(subject);
+
+ var connection = await connectable.ConnectAsync(CancellationToken.None);
+
+ await connection.DisposeAsync();
+ await connection.DisposeAsync();
+
+ await Assert.That(connection).IsNotNull();
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs
index 3fd720b..d57ce7e 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Switch.cs
@@ -516,4 +516,37 @@ await ObservableAsync.Return(ObservableAsync.Throw(error))
.Switch()
.FirstAsync());
}
+
+ /// Verifies that subscribing Switch with an already-cancelled token
+ /// short-circuits the subscription's cancellation chain immediately.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSwitchSubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await using var sub = await ObservableAsync.Return>(ObservableAsync.Return(1))
+ .Switch()
+ .SubscribeAsync(static (_, _) => default, cts.Token);
+
+ await Assert.That(sub).IsNotNull();
+ }
+
+ /// Verifies that subscribing Switch with a cancellable but not-yet-cancelled
+ /// token registers the external link and the registration fires on later cancellation.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSwitchExternalTokenCancelledAfterSubscribe_ThenRegistrationFires()
+ {
+ using var cts = new CancellationTokenSource();
+ var outer = SubjectAsync.Create>();
+
+ await using var sub = await outer.Values
+ .Switch()
+ .SubscribeAsync(static (_, _) => default, cts.Token);
+
+ await cts.CancelAsync();
+ await Assert.That(sub).IsNotNull();
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs
index dada162..8fae11d 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/CombiningOperatorTests.Zip.cs
@@ -583,4 +583,39 @@ public async Task WhenZipLeftShorter_ThenCompletesEarly()
await Assert.That(result).IsCollectionEqualTo([ZipPair11, ZipPair13]);
}
+
+ /// Verifies that subscribing Zip with an already-cancelled token
+ /// short-circuits the subscription's cancellation chain immediately.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenZipSubscribedWithAlreadyCancelledToken_ThenSubscriptionDisposes()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await using var sub = await ObservableAsync.Range(1, 2)
+ .Zip(ObservableAsync.Range(10, 2), static (a, b) => a + b)
+ .SubscribeAsync(static (_, _) => default, cts.Token);
+
+ await Assert.That(sub).IsNotNull();
+ }
+
+ /// Verifies that subscribing Zip with a cancellable but not-yet-cancelled
+ /// token registers the external link and the registration fires when the token is cancelled
+ /// after subscribe, tearing the subscription down.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenZipExternalTokenCancelledAfterSubscribe_ThenRegistrationFires()
+ {
+ using var cts = new CancellationTokenSource();
+ var left = SubjectAsync.Create();
+ var right = SubjectAsync.Create();
+
+ await using var sub = await left.Values
+ .Zip(right.Values, static (a, b) => a + b)
+ .SubscribeAsync(static (_, _) => default, cts.Token);
+
+ await cts.CancelAsync();
+ await Assert.That(sub).IsNotNull();
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ConcurrentSubjectBaseTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ConcurrentSubjectBaseTests.cs
new file mode 100644
index 0000000..e7aba77
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ConcurrentSubjectBaseTests.cs
@@ -0,0 +1,225 @@
+// 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 ReactiveUI.Extensions.Async;
+using ReactiveUI.Extensions.Async.Internals;
+using ReactiveUI.Extensions.Async.Subjects;
+
+namespace ReactiveUI.Extensions.Tests.Async;
+
+/// Coverage for the static fan-out helpers in
+/// — exercises empty / single / multi-observer paths and the
+/// slow-path that uses when at
+/// least one observer's hasn't completed synchronously.
+[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")]
+public class ConcurrentSubjectBaseTests
+{
+ /// Value forwarded by the OnNext fan-out tests.
+ private const int ForwardedValue = 42;
+
+ /// Delay in milliseconds used to force the slow-path branch.
+ private const int SlowPathDelayMilliseconds = 5;
+
+ /// Verifies that ForwardOnNextConcurrently with an empty observer list returns immediately.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForwardOnNextEmpty_ThenCompletesImmediately()
+ {
+ var empty = ImmutableArray>.Empty;
+
+ await Concurrent.ForwardOnNextConcurrently(empty, ForwardedValue, default);
+
+ // No observers → nothing to assert beyond reaching this line without throwing.
+ await Assert.That(empty.Length).IsEqualTo(0);
+ }
+
+ /// Verifies that ForwardOnNextConcurrently with a single observer forwards once.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForwardOnNextSingleObserver_ThenForwardsOnce()
+ {
+ var capture = new IntCapture();
+ var observers = ImmutableArray.Create>(MakeSync(capture));
+
+ await Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default);
+
+ await Assert.That(capture.Value).IsEqualTo(ForwardedValue);
+ }
+
+ /// Verifies that the synchronous-fast-path branch fires every observer when each
+ /// returns a synchronously-completed .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForwardOnNextMultipleSync_ThenAllReceive()
+ {
+ var a = new IntCapture();
+ var b = new IntCapture();
+ var c = new IntCapture();
+
+ var observers = ImmutableArray.Create>(
+ MakeSync(a),
+ MakeSync(b),
+ MakeSync(c));
+
+ await Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default);
+
+ await Assert.That(a.Value).IsEqualTo(ForwardedValue);
+ await Assert.That(b.Value).IsEqualTo(ForwardedValue);
+ await Assert.That(c.Value).IsEqualTo(ForwardedValue);
+ }
+
+ /// Verifies that the slow-path branch (Task.WhenAll) runs every observer when
+ /// any of them returns a non-completed .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForwardOnNextSlowPath_ThenWhenAllForwarded()
+ {
+ var a = new IntCapture();
+ var b = new IntCapture();
+ var c = new IntCapture();
+
+ var observers = ImmutableArray.Create>(
+ MakeSlow(a),
+ MakeSync(b),
+ MakeSlow(c));
+
+ await Concurrent.ForwardOnNextConcurrently(observers, ForwardedValue, default);
+
+ await Assert.That(a.Value).IsEqualTo(ForwardedValue);
+ await Assert.That(b.Value).IsEqualTo(ForwardedValue);
+ await Assert.That(c.Value).IsEqualTo(ForwardedValue);
+ }
+
+ /// Verifies the empty / single / slow-path branches of ForwardOnErrorResumeConcurrently.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForwardOnErrorResume_ThenAllBranchesForward()
+ {
+ var emptyObservers = ImmutableArray>.Empty;
+ await Concurrent.ForwardOnErrorResumeConcurrently(emptyObservers, new InvalidOperationException("empty"), default);
+
+ var singleCaught = new ErrorCapture();
+ var single = ImmutableArray.Create>(
+ new AnonymousObserverAsync(static (_, _) => default, MakeErrorSync(singleCaught)));
+ var singleError = new InvalidOperationException("single");
+ await Concurrent.ForwardOnErrorResumeConcurrently(single, singleError, default);
+ await Assert.That(singleCaught.Error).IsSameReferenceAs(singleError);
+
+ var a = new ErrorCapture();
+ var b = new ErrorCapture();
+ var multi = ImmutableArray.Create>(
+ new AnonymousObserverAsync(static (_, _) => default, MakeErrorSlow(a)),
+ new AnonymousObserverAsync(static (_, _) => default, MakeErrorSync(b)));
+ var multiError = new InvalidOperationException("multi");
+ await Concurrent.ForwardOnErrorResumeConcurrently(multi, multiError, default);
+ await Assert.That(a.Error).IsSameReferenceAs(multiError);
+ await Assert.That(b.Error).IsSameReferenceAs(multiError);
+ }
+
+ /// Verifies the empty / single / slow-path branches of ForwardOnCompletedConcurrently.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForwardOnCompleted_ThenAllBranchesForward()
+ {
+ var emptyObservers = ImmutableArray>.Empty;
+ await Concurrent.ForwardOnCompletedConcurrently(emptyObservers, Result.Success);
+
+ var singleResult = new ResultCapture();
+ var single = ImmutableArray.Create>(
+ new AnonymousObserverAsync(static (_, _) => default, null, MakeCompletedSync(singleResult)));
+ await Concurrent.ForwardOnCompletedConcurrently(single, Result.Success);
+ await Assert.That(singleResult.Result).IsEqualTo(Result.Success);
+
+ var a = new ResultCapture();
+ var b = new ResultCapture();
+ var multi = ImmutableArray.Create>(
+ new AnonymousObserverAsync(static (_, _) => default, null, MakeCompletedSlow(a)),
+ new AnonymousObserverAsync(static (_, _) => default, null, MakeCompletedSync(b)));
+ await Concurrent.ForwardOnCompletedConcurrently(multi, Result.Success);
+ await Assert.That(a.Result).IsEqualTo(Result.Success);
+ await Assert.That(b.Result).IsEqualTo(Result.Success);
+ }
+
+ /// Creates a synchronously-completing OnNext observer that captures the value.
+ /// The capture sink.
+ /// An observer whose OnNextAsync completes synchronously.
+ private static AnonymousObserverAsync MakeSync(IntCapture capture) =>
+ new((x, _) =>
+ {
+ capture.Value = x;
+ return default;
+ });
+
+ /// Creates an OnNext observer that delays before capturing — forces the slow path.
+ /// The capture sink.
+ /// An observer whose OnNextAsync completes asynchronously.
+ private static AnonymousObserverAsync MakeSlow(IntCapture capture) =>
+ new(async (x, ct) =>
+ {
+ await Task.Delay(SlowPathDelayMilliseconds, ct).ConfigureAwait(false);
+ capture.Value = x;
+ });
+
+ /// Synchronously-completing OnErrorResume handler that records the exception.
+ /// The capture sink.
+ /// An OnErrorResume delegate.
+ private static Func MakeErrorSync(ErrorCapture capture) =>
+ (ex, _) =>
+ {
+ capture.Error = ex;
+ return default;
+ };
+
+ /// OnErrorResume handler that delays before recording — forces the slow path.
+ /// The capture sink.
+ /// An OnErrorResume delegate.
+ private static Func MakeErrorSlow(ErrorCapture capture) =>
+ async (ex, ct) =>
+ {
+ await Task.Delay(SlowPathDelayMilliseconds, ct).ConfigureAwait(false);
+ capture.Error = ex;
+ };
+
+ /// Synchronously-completing OnCompleted handler that records the result.
+ /// The capture sink.
+ /// An OnCompleted delegate.
+ private static Func MakeCompletedSync(ResultCapture capture) =>
+ r =>
+ {
+ capture.Result = r;
+ return default;
+ };
+
+ /// OnCompleted handler that delays before recording — forces the slow path.
+ /// The capture sink.
+ /// An OnCompleted delegate.
+ private static Func MakeCompletedSlow(ResultCapture capture) =>
+ async r =>
+ {
+ await Task.Delay(SlowPathDelayMilliseconds, CancellationToken.None).ConfigureAwait(false);
+ capture.Result = r;
+ };
+
+ /// Mutable holder for an captured by an observer delegate.
+ private sealed class IntCapture
+ {
+ /// Gets or sets the captured value.
+ public int Value { get; set; }
+ }
+
+ /// Mutable holder for an captured by an observer delegate.
+ private sealed class ErrorCapture
+ {
+ /// Gets or sets the captured exception.
+ public Exception? Error { get; set; }
+ }
+
+ /// Mutable holder for a captured by an observer delegate.
+ private sealed class ResultCapture
+ {
+ /// Gets or sets the captured result.
+ public Result? Result { get; set; }
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs
index bc02319..337d23d 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ErrorHandlingOperatorTests.cs
@@ -356,6 +356,49 @@ public async Task WhenCatchDisposed_ThenDisposesSourceAndHandler()
await sub.DisposeAsync();
}
+ /// Exercises the CatchObserver.DisposeAsyncCore catch branch — when the
+ /// handler-produced subscription throws on , the
+ /// failure is routed through rather than re-thrown.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenCatchHandlerDisposeThrows_ThenRoutedToUnhandled()
+ {
+ var previousHandler = UnhandledExceptionHandler.CurrentHandler;
+ try
+ {
+ Exception? unhandled = null;
+ var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ UnhandledExceptionHandler.Register(ex =>
+ {
+ unhandled = ex;
+ unhandledTcs.TrySetResult();
+ });
+
+ var disposeFailure = new InvalidOperationException("handler-dispose-failed");
+ var source = ObservableAsync.Throw(new InvalidOperationException("fail"));
+ var handlerSubscribed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var handlerObservable = ObservableAsync.Create((_, _) =>
+ {
+ handlerSubscribed.TrySetResult();
+ return new ValueTask(new ThrowingDisposable(disposeFailure));
+ });
+
+ var sub = await source.Catch(_ => handlerObservable)
+ .SubscribeAsync(static (_, _) => default, null, static _ => default);
+
+ await handlerSubscribed.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await sub.DisposeAsync();
+ await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(unhandled).IsSameReferenceAs(disposeFailure);
+ }
+ finally
+ {
+ UnhandledExceptionHandler.Register(previousHandler);
+ }
+ }
+
/// Tests that CatchAndIgnoreErrorResume invokes the unhandled exception handler for error resumes.
/// A representing the asynchronous test operation.
[Test]
@@ -488,4 +531,12 @@ public async Task WhenRetryParameterless_ThenRetriesUntilSuccess()
await Assert.That(result).IsCollectionEqualTo([SuccessValue]);
await Assert.That(attempt).IsEqualTo(ExpectedAttempts);
}
+
+ /// Async disposable that throws on .
+ /// Used to verify dispose-failure routing in operators that swallow secondary errors.
+ private sealed class ThrowingDisposable(Exception error) : IAsyncDisposable
+ {
+ ///
+ public ValueTask DisposeAsync() => throw error;
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs
index 616fdd3..d049485 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/FilteringOperatorTests.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for full license information.
using ReactiveUI.Extensions.Async;
+using ReactiveUI.Extensions.Async.Subjects;
namespace ReactiveUI.Extensions.Tests.Async;
@@ -355,4 +356,362 @@ public async Task WhenDistinctUntilChangedBy_ThenDistinguishesByKey()
await Assert.That(result).IsCollectionEqualTo(["aa", "ba"]);
}
+
+ /// Verifies that sync-predicate SkipWhile forwards a non-terminal upstream error
+ /// through its OnErrorResume path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSkipWhileSyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .SkipWhile(static x => x < 2)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("skip-while-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that sync-predicate TakeWhile forwards a non-terminal upstream error
+ /// through its OnErrorResume path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTakeWhileSyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .TakeWhile(static x => x < 10)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("take-while-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies the async-predicate SkipWhile sync-completed predicate path —
+ /// returning drops the value, returning latches
+ /// the gate and forwards.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSkipWhileAsyncWithSyncPredicate_ThenLatchesOnFalse()
+ {
+ var result = await ObservableAsync.Range(1, 5)
+ .SkipWhile(static (x, _) => new ValueTask(x < 3))
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo([ThirdElement, FourthElement, FifthElement]);
+ }
+
+ /// Verifies the async-predicate TakeWhile sync-completed predicate path —
+ /// returning forwards, returning terminates.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTakeWhileAsyncWithSyncPredicate_ThenTerminatesOnFalse()
+ {
+ var result = await ObservableAsync.Range(1, 5)
+ .TakeWhile(static (x, _) => new ValueTask(x < 3))
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo([1, SecondElement]);
+ }
+
+ /// Verifies that async-predicate SkipWhile forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSkipWhileAsyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .SkipWhile(static (_, _) => new ValueTask(true))
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("skip-while-async-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that async-predicate TakeWhile forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTakeWhileAsyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .TakeWhile(static (_, _) => new ValueTask(true))
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("take-while-async-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the DistinctObserver.OnErrorResumeAsyncCore forwarding —
+ /// upstream resumable errors propagate verbatim to the downstream observer.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDistinctSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .Distinct()
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("distinct-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the DistinctByObserver.OnErrorResumeAsyncCore forwarding.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDistinctBySourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .DistinctBy(static x => x)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("distinct-by-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the DistinctUntilChangedObserver.OnErrorResumeAsyncCore forwarding.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDistinctUntilChangedSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .DistinctUntilChanged()
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("distinct-until-changed-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the DistinctUntilChangedByObserver.OnErrorResumeAsyncCore forwarding.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDistinctUntilChangedBySourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .DistinctUntilChangedBy(static x => x)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("distinct-until-changed-by-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the WhereSyncObserver.OnErrorResumeAsyncCore forwarding —
+ /// the synchronous-predicate overload forwards upstream resumable errors.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereSyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .Where(static _ => true)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("where-sync-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the SelectSyncObserver.OnErrorResumeAsyncCore forwarding —
+ /// upstream resumable errors propagate verbatim through the synchronous Select observer.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSelectSyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .Select(static x => x + 1)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("select-sync-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the SelectAsyncObserver.OnErrorResumeAsyncCore forwarding —
+ /// the async-selector overload forwards upstream resumable errors.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSelectAsyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .Select(static (x, _) => new ValueTask(x + 1))
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("select-async-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Exercises the WhereAsyncObserver.OnErrorResumeAsyncCore forwarding —
+ /// the async-predicate overload forwards upstream resumable errors.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereAsyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .Where(static (_, _) => new ValueTask(true))
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("where-async-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ObserveOnAsyncObservableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ObserveOnAsyncObservableTests.cs
new file mode 100644
index 0000000..41c47a6
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ObserveOnAsyncObservableTests.cs
@@ -0,0 +1,222 @@
+// 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;
+
+namespace ReactiveUI.Extensions.Tests.Async;
+
+/// Tests for — exercises the
+/// forceYielding: true slow-path branches that switch context on every
+/// OnNext / OnErrorResume / OnCompleted regardless of whether
+/// the call site is already on the target context.
+public class ObserveOnAsyncObservableTests
+{
+ /// Single sentinel emitted by the happy-path tests.
+ private const int Sentinel = 7;
+
+ /// Verifies the forceYielding: true overload forwards values via the
+ /// context-switching slow path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForceYielding_ThenValueForwarded()
+ {
+ var result = await ObservableAsync.Return(Sentinel)
+ .ObserveOn(AsyncContext.Default, forceYielding: true)
+ .FirstAsync();
+
+ await Assert.That(result).IsEqualTo(Sentinel);
+ }
+
+ /// Verifies the forceYielding: true overload routes OnErrorResume
+ /// through the context-switching slow path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForceYieldingSourceErrors_ThenErrorForwarded()
+ {
+ var expected = new InvalidOperationException("forced");
+ InvalidOperationException? caught = null;
+
+ try
+ {
+ await ObservableAsync.Throw(expected)
+ .ObserveOn(AsyncContext.Default, forceYielding: true)
+ .ToListAsync();
+ }
+ catch (InvalidOperationException ex)
+ {
+ caught = ex;
+ }
+
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies the forceYielding: true overload routes the completion
+ /// notification through the context-switching slow path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForceYieldingSourceEmpty_ThenCompletesSuccessfully()
+ {
+ var result = await ObservableAsync.Empty()
+ .ObserveOn(AsyncContext.Default, forceYielding: true)
+ .ToListAsync();
+
+ await Assert.That(result).IsEmpty();
+ }
+
+ /// Verifies the SynchronizationContext + forceYielding: true overload
+ /// also forwards values.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSyncContextForceYielding_ThenEmits()
+ {
+ var ctx = SynchronizationContext.Current ?? new SynchronizationContext();
+
+ var result = await ObservableAsync.Return(Sentinel)
+ .ObserveOn(ctx, forceYielding: true)
+ .FirstAsync();
+
+ await Assert.That(result).IsEqualTo(Sentinel);
+ }
+
+ /// Verifies that ObserveOn with a different SynchronizationContext routes
+ /// the error through the slow-path context-switch even when forceYielding is false.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenObserveOnDifferentContextSourceErrors_ThenForwardedViaSlowPath()
+ {
+ var expected = new InvalidOperationException("differing-context-error");
+ InvalidOperationException? caught = null;
+ var customCtx = new SynchronizationContext();
+
+ try
+ {
+ await ObservableAsync.Throw(expected)
+ .ObserveOn(customCtx, forceYielding: false)
+ .ToListAsync();
+ }
+ catch (InvalidOperationException ex)
+ {
+ caught = ex;
+ }
+
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that ObserveOn with a different SynchronizationContext routes
+ /// the completion through the slow-path context-switch even when forceYielding is false.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenObserveOnDifferentContextSourceEmpty_ThenCompletesViaSlowPath()
+ {
+ var customCtx = new SynchronizationContext();
+
+ var result = await ObservableAsync.Empty()
+ .ObserveOn(customCtx, forceYielding: false)
+ .ToListAsync();
+
+ await Assert.That(result).IsEmpty();
+ }
+
+ /// Verifies ObserveOnObserver.SwitchThenForwardAsync by calling it directly
+ /// — the slow path performs the context switch and then forwards the value downstream,
+ /// independent of the fast/slow choice in OnNextAsyncCore.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSwitchThenForwardAsyncInvokedDirectly_ThenValueForwarded()
+ {
+ var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var downstream = new CapturingAsyncObserver(captured);
+ var sut = new ObserveOnAsyncObservable.ObserveOnObserver(downstream, AsyncContext.Default, forceYielding: true);
+
+ await sut.SwitchThenForwardAsync(Sentinel, CancellationToken.None);
+
+ var received = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(received).IsEqualTo(Sentinel);
+ }
+
+ /// Verifies ObserveOnObserver.SwitchThenErrorAsync by calling it directly.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSwitchThenErrorAsyncInvokedDirectly_ThenErrorForwarded()
+ {
+ var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var downstream = new CapturingAsyncObserver(captured);
+ var sut = new ObserveOnAsyncObservable.ObserveOnObserver(downstream, AsyncContext.Default, forceYielding: true);
+ var expected = new InvalidOperationException("slow-path-error");
+
+ await sut.SwitchThenErrorAsync(expected, CancellationToken.None);
+
+ var received = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(received).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies ObserveOnObserver.SwitchThenCompletedAsync by calling it directly.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSwitchThenCompletedAsyncInvokedDirectly_ThenCompletionForwarded()
+ {
+ var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var downstream = new CapturingAsyncObserver(captured);
+ var sut = new ObserveOnAsyncObservable.ObserveOnObserver(downstream, AsyncContext.Default, forceYielding: true);
+
+ await sut.SwitchThenCompletedAsync(Result.Success);
+
+ var result = await captured.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(result.IsSuccess).IsTrue();
+ }
+
+ /// Test observer that captures the first OnNextAsync value, the first
+ /// OnErrorResumeAsync exception, and the OnCompletedAsync result via TCSes.
+ /// The element type.
+ private sealed class CapturingAsyncObserver : IObserverAsync
+ {
+ /// Captures the first OnNextAsync value, if a TCS was supplied.
+ private readonly TaskCompletionSource? _onNext;
+
+ /// Captures the first OnErrorResumeAsync exception, if a TCS was supplied.
+ private readonly TaskCompletionSource? _onError;
+
+ /// Captures the OnCompletedAsync result, if a TCS was supplied.
+ private readonly TaskCompletionSource? _onCompleted;
+
+ /// Initializes a new instance of the class
+ /// with an OnNext capture target.
+ /// The TCS that receives the first OnNextAsync value.
+ public CapturingAsyncObserver(TaskCompletionSource onNext) => _onNext = onNext;
+
+ /// Initializes a new instance of the class
+ /// with an OnErrorResume capture target.
+ /// The TCS that receives the first OnErrorResumeAsync exception.
+ public CapturingAsyncObserver(TaskCompletionSource onError) => _onError = onError;
+
+ /// Initializes a new instance of the class
+ /// with an OnCompleted capture target.
+ /// The TCS that receives the OnCompletedAsync result.
+ public CapturingAsyncObserver(TaskCompletionSource onCompleted) => _onCompleted = onCompleted;
+
+ ///
+ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken)
+ {
+ _onNext?.TrySetResult(value);
+ return default;
+ }
+
+ ///
+ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken)
+ {
+ _onError?.TrySetResult(error);
+ return default;
+ }
+
+ ///
+ public ValueTask OnCompletedAsync(Result result)
+ {
+ _onCompleted?.TrySetResult(result);
+ return default;
+ }
+
+ ///
+ public ValueTask DisposeAsync() => default;
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersFilterFusionsTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersFilterFusionsTests.cs
new file mode 100644
index 0000000..1774606
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersFilterFusionsTests.cs
@@ -0,0 +1,403 @@
+// 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;
+using ReactiveUI.Extensions.Async.Subjects;
+
+namespace ReactiveUI.Extensions.Tests.Async;
+
+/// Coverage for the error-forwarding and edge cases of the fused
+/// async filter operators in ParityHelpers.FilterFusions —
+/// SkipWhileNull, WhereIsNotNull, LatestOrDefault,
+/// WaitUntil, AsSignal, Not, WhereTrue, WhereFalse.
+[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")]
+public class ParityHelpersFilterFusionsTests
+{
+ /// Sentinel "found" sentinel string.
+ private const string Hit = "hit";
+
+ /// Expected count of bool outputs that pass / fail their filter.
+ private const int ExpectedBoolFilterCount = 2;
+
+ /// Expected count of unit signals emitted by the AsSignal test.
+ private const int ExpectedSignalCount = 3;
+
+ /// Predicate threshold for the WaitUntil test.
+ private const int WaitUntilThreshold = 3;
+
+ /// Verifies that SkipWhileNull drops leading nulls then forwards every value.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSkipWhileNull_ThenDropsLeadingNullsThenLatches()
+ {
+ string?[] inputs = [null, null, "a", null, "b"];
+
+ var result = await inputs.ToObservableAsync()
+ .SkipWhileNull()
+ .ToListAsync();
+
+ // After the first non-null, the gate opens and every subsequent value (including null!) flows.
+ // The implementation forwards `value!` past the gate; we assert the non-null prefix is correct
+ // and we receive at least the values after the gate opened.
+ await Assert.That(result.Count).IsGreaterThanOrEqualTo(1);
+ await Assert.That(result[0]).IsEqualTo("a");
+ }
+
+ /// Verifies that WhereIsNotNull strips nulls and forwards non-nulls.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereIsNotNull_ThenForwardsNonNullsOnly()
+ {
+ string?[] inputs = [null, "a", null, "b", null];
+
+ var result = await inputs.ToObservableAsync()
+ .WhereIsNotNull()
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo(["a", "b"]);
+ }
+
+ /// Verifies that LatestOrDefault emits the seed first, then suppresses
+ /// values equal to the most-recent emitted, then forwards every distinct value.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenLatestOrDefault_ThenSeedFirstAndDistinctOnly()
+ {
+ const int Zero = 0;
+ const int One = 1;
+ const int Two = 2;
+ int[] inputs = [Zero, Zero, One, One, Two];
+
+ var result = await inputs.ToObservableAsync()
+ .LatestOrDefault(Zero)
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo([Zero, One, Two]);
+ }
+
+ /// Verifies that WaitUntil emits the first matching value and completes,
+ /// dropping subsequent values.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWaitUntilMatches_ThenEmitsFirstHitAndCompletes()
+ {
+ int[] inputs = [1, 2, 3, 4, 5];
+
+ var result = await inputs.ToObservableAsync()
+ .WaitUntil(static x => x >= WaitUntilThreshold)
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo([WaitUntilThreshold]);
+ }
+
+ /// Verifies that WaitUntil with no match completes empty.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWaitUntilNoMatch_ThenCompletesEmpty()
+ {
+ int[] inputs = [1, 2, 3];
+
+ var result = await inputs.ToObservableAsync()
+ .WaitUntil(static _ => false)
+ .ToListAsync();
+
+ await Assert.That(result).IsEmpty();
+ }
+
+ /// Verifies that AsSignal projects every emission to .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenAsSignal_ThenProjectsToUnit()
+ {
+ int[] inputs = [1, 2, 3];
+
+ var result = await inputs.ToObservableAsync()
+ .AsSignal()
+ .ToListAsync();
+
+ await Assert.That(result.Count).IsEqualTo(ExpectedSignalCount);
+ for (var i = 0; i < result.Count; i++)
+ {
+ await Assert.That(result[i]).IsEqualTo(Unit.Default);
+ }
+ }
+
+ /// Verifies that Not negates every boolean emission.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenNot_ThenNegatesEvery()
+ {
+ bool[] inputs = [true, false, true];
+
+ var result = await inputs.ToObservableAsync()
+ .Not()
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo([false, true, false]);
+ }
+
+ /// Verifies that WhereTrue forwards only true values.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereTrue_ThenForwardsOnlyTrue()
+ {
+ bool[] inputs = [true, false, true, false];
+
+ var result = await inputs.ToObservableAsync()
+ .WhereTrue()
+ .ToListAsync();
+
+ await Assert.That(result.Count).IsEqualTo(ExpectedBoolFilterCount);
+ for (var i = 0; i < result.Count; i++)
+ {
+ await Assert.That(result[i]).IsTrue();
+ }
+ }
+
+ /// Verifies that WhereFalse forwards only false values.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereFalse_ThenForwardsOnlyFalse()
+ {
+ bool[] inputs = [true, false, true, false];
+
+ var result = await inputs.ToObservableAsync()
+ .WhereFalse()
+ .ToListAsync();
+
+ await Assert.That(result.Count).IsEqualTo(ExpectedBoolFilterCount);
+ for (var i = 0; i < result.Count; i++)
+ {
+ await Assert.That(result[i]).IsFalse();
+ }
+ }
+
+ /// Verifies that WhereIsNotNull forwards OnErrorResume downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereIsNotNullSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? received = null;
+
+ await using var sub = await subject.Values
+ .WhereIsNotNull()
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ received = ex;
+ return default;
+ });
+
+ await subject.OnErrorResumeAsync(new InvalidOperationException(Hit), CancellationToken.None);
+
+ await AsyncTestHelpers.WaitForConditionAsync(
+ () => received is not null,
+ TimeSpan.FromSeconds(5));
+
+ await Assert.That(received).IsNotNull();
+ await Assert.That(received!.Message).IsEqualTo(Hit);
+ }
+
+ /// Verifies that Pairwise forwards a non-terminal upstream error downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenPairwiseSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.Pairwise().SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("pairwise-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that SkipWhileNull forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSkipWhileNullSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.SkipWhileNull().SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("skip-while-null-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that LatestOrDefault forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenLatestOrDefaultSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.LatestOrDefault(0).SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("latest-or-default-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that WaitUntil forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWaitUntilSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.WaitUntil(static _ => false).SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("wait-until-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that AsSignal forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenAsSignalSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.AsSignal().SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("as-signal-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that Not forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenAsyncNotSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.Not().SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("not-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that WhereTrue forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereTrueSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.WhereTrue().SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("where-true-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that WhereFalse forwards a non-terminal upstream error.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenWhereFalseSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.WhereFalse().SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("where-false-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersOperatorFusionsTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersOperatorFusionsTests.cs
new file mode 100644
index 0000000..a87fef8
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ParityHelpersOperatorFusionsTests.cs
@@ -0,0 +1,854 @@
+// 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;
+using ReactiveUI.Extensions.Async.Disposables;
+using ReactiveUI.Extensions.Async.Subjects;
+
+namespace ReactiveUI.Extensions.Tests.Async;
+
+/// Edge-case coverage for the fused async operators in
+/// ParityHelpers.OperatorFusions — async ScanWithInitial,
+/// ThrottleDistinct upstream/downstream filtering, DebounceUntil
+/// immediate-bypass branch, and the typed fast paths in ForEach
+/// (array / IReadOnlyList / general IEnumerable).
+[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "TUnit requires instance methods")]
+public class ParityHelpersOperatorFusionsTests
+{
+ /// Initial accumulator seed for scan tests.
+ private const int ScanSeed = 0;
+
+ /// Throttle window in milliseconds for ThrottleDistinct tests.
+ private const int ThrottleWindowMilliseconds = 50;
+
+ /// Sentinel one.
+ private const int One = 1;
+
+ /// Sentinel two.
+ private const int Two = 2;
+
+ /// Sentinel three.
+ private const int Three = 3;
+
+ /// Sentinel four.
+ private const int Four = 4;
+
+ /// Array sentinels for the array fast-path test.
+ private static readonly int[] ArraySlice1 = [One, Two];
+
+ /// Second array sentinels.
+ private static readonly int[] ArraySlice2 = [Three, Four];
+
+ /// Expected flat result for the array fast-path test.
+ private static readonly int[] ExpectedArrayFlat = [One, Two, Three, Four];
+
+ /// Expected flat result for the list and enumerable tests.
+ private static readonly int[] ExpectedListFlat = [One, Two, Three];
+
+ /// Inputs for the ScanWithInitial async-accumulator test.
+ private static readonly int[] ScanInputs = [One, Two, Three];
+
+ /// Inputs for the ThrottleDistinct rapid-values test.
+ private static readonly int[] ThrottleRapidInputs = [One, Two, Three];
+
+ /// Inputs for the DebounceUntil immediate-bypass test.
+ private static readonly int[] DebounceInputs = [One, Two, Three];
+
+ /// Inputs of all-equal values for the ThrottleDistinct duplicates test.
+ private static readonly int[] ThrottleDuplicateInputs = [One, One, One];
+
+ /// Verifies that the async-accumulator overload of ScanWithInitial
+ /// emits the seed first then every intermediate value produced by the asynchronous fold.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenScanWithInitialAsync_ThenSeedThenAsyncFolded()
+ {
+ var result = await ScanInputs.ToObservableAsync()
+ .ScanWithInitial(ScanSeed, static async (acc, x, _) =>
+ {
+ await Task.Yield();
+ return acc + x;
+ })
+ .ToListAsync();
+
+ int[] expected =
+ [
+ ScanSeed,
+ One,
+ One + Two,
+ One + Two + Three
+ ];
+ await Assert.That(result).IsCollectionEqualTo(expected);
+ }
+
+ /// Verifies that ThrottleDistinct suppresses consecutive duplicates upstream
+ /// before any throttle work is scheduled.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenThrottleDistinctConsecutiveDuplicates_ThenSuppressesUpstream()
+ {
+ var result = await ThrottleDuplicateInputs.ToObservableAsync()
+ .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds))
+ .ToListAsync();
+
+ // All inputs are equal — only one emission is ever scheduled, and the source completes
+ // before the throttle window elapses, so the pending emission must still flush exactly once.
+ await Assert.That(result.Count).IsLessThanOrEqualTo(1);
+ }
+
+ /// Verifies that ThrottleDistinct with distinct rapid values respects the
+ /// no-consecutive-duplicates contract and never emits more than the input count.
+ /// (Pending throttled emissions are superseded by source completion — this is the
+ /// documented behavior, so a count-bound assertion is the appropriate check rather than
+ /// "at least one emission".)
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenThrottleDistinctRapidDistinctValues_ThenNoConsecutiveDuplicates()
+ {
+ var result = await ThrottleRapidInputs.ToObservableAsync()
+ .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds))
+ .ToListAsync();
+
+ await Assert.That(result.Count).IsLessThanOrEqualTo(ThrottleRapidInputs.Length);
+ for (var i = 1; i < result.Count; i++)
+ {
+ await Assert.That(result[i]).IsNotEqualTo(result[i - 1]);
+ }
+ }
+
+ /// Verifies that DebounceUntil with an always-true condition bypasses
+ /// the debounce window and emits inline.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDebounceUntilConditionAlwaysTrue_ThenEmitsImmediately()
+ {
+ var result = await DebounceInputs.ToObservableAsync()
+ .DebounceUntil(TimeSpan.FromSeconds(5), static _ => true)
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo(DebounceInputs);
+ }
+
+ /// Verifies that the array fast path of ForEach flattens an
+ /// IObservableAsync<T[]> into a flat sequence of elements.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForEachOverArray_ThenUsesArrayFastPath()
+ {
+ IEnumerable[] arrays = [ArraySlice1, ArraySlice2];
+
+ var result = await arrays.ToObservableAsync()
+ .ForEach()
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo(ExpectedArrayFlat);
+ }
+
+ /// Verifies that the fast path of ForEach
+ /// flattens a list-typed source.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForEachOverReadOnlyList_ThenUsesListFastPath()
+ {
+ var firstList = new List(ArraySlice1);
+ var secondList = new List(1) { Three };
+ IEnumerable[] lists = [firstList, secondList];
+
+ var result = await lists.ToObservableAsync()
+ .ForEach()
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo(ExpectedListFlat);
+ }
+
+ /// Verifies that the general path of ForEach
+ /// flattens a non-array, non-list source.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForEachOverGenericEnumerable_ThenUsesEnumeratorPath()
+ {
+ IEnumerable[] enumerables = [Enumerate(One, Two), Enumerate(Three)];
+
+ var result = await enumerables.ToObservableAsync()
+ .ForEach()
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo(ExpectedListFlat);
+ }
+
+ /// Verifies that Partition broadcasts an upstream non-terminal error to both
+ /// subscribed branches via the OnErrorResume path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenPartitionSourceErrorResume_ThenBothBranchesReceiveError()
+ {
+ var subject = SubjectAsync.Create();
+ var (evens, odds) = subject.Values.Partition(static x => x % Two == 0);
+
+ Exception? evenError = null;
+ Exception? oddError = null;
+ var evenTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var oddTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var evenSub = await evens.SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ evenError = ex;
+ evenTcs.TrySetResult();
+ return default;
+ });
+ await using var oddSub = await odds.SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ oddError = ex;
+ oddTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("partition-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await Task.WhenAll(evenTcs.Task, oddTcs.Task).WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(evenError).IsSameReferenceAs(expected);
+ await Assert.That(oddError).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that a branch subscriber attaching after the source has already
+ /// completed gets the cached terminal forwarded immediately.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenPartitionLateBranchSubscribesAfterCompletion_ThenCachedTerminalForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ var (evens, odds) = subject.Values.Partition(static x => x % Two == 0);
+
+ var firstTask = evens.ToListAsync().AsTask();
+ await subject.OnNextAsync(Two, CancellationToken.None);
+ await subject.OnCompletedAsync(Result.Success);
+ await firstTask;
+
+ var lateValues = new List();
+ var lateCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var lateSub = await odds.SubscribeAsync(
+ (v, _) =>
+ {
+ lateValues.Add(v);
+ return default;
+ },
+ (_, _) => default,
+ result =>
+ {
+ lateCompleted.TrySetResult();
+ return default;
+ });
+
+ await lateCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(lateValues).IsEmpty();
+ }
+
+ /// Verifies that DropIfBusy resets the busy flag and re-throws when the
+ /// async action throws synchronously (rather than returning a faulted task).
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDropIfBusyActionThrowsSynchronously_ThenBusyFlagResetAndErrorObserved()
+ {
+ var failure = new InvalidOperationException("sync-throw");
+ InvalidOperationException? observed = null;
+
+ try
+ {
+ await new[] { One }.ToObservableAsync()
+ .DropIfBusy(static (_, _) => throw new InvalidOperationException("sync-throw"))
+ .ToListAsync();
+ }
+ catch (InvalidOperationException ex)
+ {
+ observed = ex;
+ }
+
+ await Assert.That(observed).IsNotNull();
+ await Assert.That(observed!.Message).IsEqualTo(failure.Message);
+ }
+
+ /// Verifies that ScanWithInitial forwards a non-terminal upstream error
+ /// downstream while still emitting the seed.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenScanWithInitialSourceErrorResumes_ThenForwardsDownstream()
+ {
+ var subject = SubjectAsync.Create();
+ var values = new List();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .ScanWithInitial(ScanSeed, static (acc, x) => acc + x)
+ .SubscribeAsync(
+ (v, _) =>
+ {
+ values.Add(v);
+ return default;
+ },
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("scan-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ await Assert.That(values).IsCollectionEqualTo([ScanSeed]);
+ }
+
+ /// Verifies that ThrottleDistinct forwards a non-terminal upstream error
+ /// downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenThrottleDistinctSourceErrorResumes_ThenForwardsDownstream()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds))
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("throttle-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that DebounceUntil forwards a non-terminal upstream error
+ /// downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDebounceUntilSourceErrorResumes_ThenForwardsDownstream()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .DebounceUntil(TimeSpan.FromSeconds(5), static _ => false)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("debounce-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that ForEach forwards a non-terminal upstream error downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForEachSourceErrorResumes_ThenForwardsDownstream()
+ {
+ var subject = SubjectAsync.Create>();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .ForEach()
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("foreach-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that DropIfBusy forwards a non-terminal upstream error downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDropIfBusySourceErrorResumes_ThenForwardsDownstream()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .DropIfBusy(static (_, _) => default)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("dropifbusy-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that DropIfBusy with a sync action but an asynchronously-completing
+ /// downstream takes the AwaitForwardAsync slow path and resets the busy flag in
+ /// its finally.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDropIfBusySyncActionAsyncDownstream_ThenAwaitForwardSlowPathResets()
+ {
+ var subject = SubjectAsync.Create();
+ var values = new List();
+ var emittedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .DropIfBusy(static (_, _) => default)
+ .SubscribeAsync(async (v, _) =>
+ {
+ await Task.Yield();
+ values.Add(v);
+ emittedTcs.TrySetResult(v);
+ });
+
+ await subject.OnNextAsync(One, CancellationToken.None);
+ await emittedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ // After the slow path resets _isBusy, a second emission must also flow through.
+ var secondTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var sub2 = await subject.Values
+ .DropIfBusy(static (_, _) => default)
+ .SubscribeAsync(async (_, _) =>
+ {
+ await Task.Yield();
+ secondTcs.TrySetResult();
+ });
+
+ await subject.OnNextAsync(Two, CancellationToken.None);
+ await secondTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(values).Contains(One);
+ }
+
+ /// Verifies that the async-accumulator ScanWithInitial overload forwards
+ /// upstream non-terminal errors downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenScanWithInitialAsyncSourceErrorResumes_ThenForwardsDownstream()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .ScanWithInitial(ScanSeed, static (acc, x, _) => new ValueTask(acc + x))
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("scan-async-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that a branch subscription disposes idempotently — the second
+ /// DisposeAsync is a no-op via the latched-int short-circuit.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenPartitionBranchDisposedTwice_ThenIdempotent()
+ {
+ var subject = SubjectAsync.Create();
+ var (evens, _) = subject.Values.Partition(static x => x % Two == 0);
+
+ var sub = await evens.SubscribeAsync(static (_, _) => default);
+
+ await sub.DisposeAsync();
+ await sub.DisposeAsync();
+
+ // Subsequent emissions must not throw and the result of pushing a value is captured.
+ await subject.OnNextAsync(Two, CancellationToken.None);
+ }
+
+ /// Verifies that the ObserverAsync base class's LinkExternalCancellation
+ /// takes the already-cancelled fast path when a fused operator's sink is constructed with
+ /// a pre-cancelled subscribe token.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenFusedOperatorSubscribedWithAlreadyCancelledToken_ThenSinkCancelsImmediately()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ try
+ {
+ await using var sub = await new[] { One, Two }.ToObservableAsync()
+ .ScanWithInitial(ScanSeed, static (acc, x) => acc + x)
+ .SubscribeAsync(static (_, _) => default, cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected — the sink is constructed with a cancelled token and the pipeline
+ // short-circuits via OperationCanceledException somewhere in the subscribe chain.
+ }
+ }
+
+ /// Verifies that an unhandled exception thrown by the downstream observer inside
+ /// ThrottleDistinct's delayed-emit task is routed to
+ /// .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenThrottleDistinctDownstreamThrowsInDelay_ThenRoutedToUnhandled()
+ {
+ var previousHandler = UnhandledExceptionHandler.CurrentHandler;
+ try
+ {
+ Exception? unhandled = null;
+ var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ UnhandledExceptionHandler.Register(ex =>
+ {
+ unhandled = ex;
+ unhandledTcs.TrySetResult();
+ });
+
+ var subject = SubjectAsync.Create();
+ var throwingObserver = new ThrowingAsyncObserver(new InvalidOperationException("downstream-throws"));
+
+ await using var sub = await subject.Values
+ .ThrottleDistinct(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds))
+ .SubscribeAsync(throwingObserver, CancellationToken.None);
+
+ await subject.OnNextAsync(One, CancellationToken.None);
+ await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(unhandled).IsNotNull();
+ await Assert.That(unhandled!.Message).IsEqualTo("downstream-throws");
+ }
+ finally
+ {
+ UnhandledExceptionHandler.Register(previousHandler);
+ }
+ }
+
+ /// Verifies that an exception thrown by the downstream observer inside Throttle's
+ /// post-delay forwarding is caught by the operator's catch (Exception e) block and
+ /// routed through .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenThrottleDownstreamThrowsInDelay_ThenRoutedToUnhandled()
+ {
+ var previousHandler = UnhandledExceptionHandler.CurrentHandler;
+ try
+ {
+ Exception? unhandled = null;
+ var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ UnhandledExceptionHandler.Register(ex =>
+ {
+ unhandled = ex;
+ unhandledTcs.TrySetResult();
+ });
+
+ var subject = SubjectAsync.Create();
+ var throwingObserver = new ThrowingAsyncObserver(new InvalidOperationException("throttle-downstream-throws"));
+
+ await using var sub = await subject.Values
+ .Throttle(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds))
+ .SubscribeAsync(throwingObserver, CancellationToken.None);
+
+ await subject.OnNextAsync(One, CancellationToken.None);
+ await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(unhandled).IsNotNull();
+ await Assert.That(unhandled!.Message).IsEqualTo("throttle-downstream-throws");
+ }
+ finally
+ {
+ UnhandledExceptionHandler.Register(previousHandler);
+ }
+ }
+
+ /// Exercises the !IsCurrentEmission(id) guard inside DebounceUntil's
+ /// DelayAndEmitAsync — when a later emission supersedes the current pending one
+ /// before its debounce window elapses, the older delayed-emit task wakes, sees its id is
+ /// stale, and returns early without forwarding.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDebounceUntilSecondEmissionSupersedesFirst_ThenStaleDelayDropsValue()
+ {
+ var subject = SubjectAsync.Create();
+ var values = new List();
+ var emitted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .DebounceUntil(TimeSpan.FromMilliseconds(80), static _ => false)
+ .SubscribeAsync(
+ (v, _) =>
+ {
+ values.Add(v);
+ emitted.TrySetResult();
+ return default;
+ });
+
+ await subject.OnNextAsync(One, CancellationToken.None);
+ await subject.OnNextAsync(Two, CancellationToken.None);
+
+ await emitted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Task.Delay(ThrottleWindowMilliseconds);
+
+ await Assert.That(values).IsCollectionEqualTo([Two]);
+ }
+
+ /// Verifies that an unhandled exception thrown by the downstream observer inside
+ /// DebounceUntil's delayed-emit task is routed to .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDebounceUntilDownstreamThrowsInDelay_ThenRoutedToUnhandled()
+ {
+ var previousHandler = UnhandledExceptionHandler.CurrentHandler;
+ try
+ {
+ Exception? unhandled = null;
+ var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ UnhandledExceptionHandler.Register(ex =>
+ {
+ unhandled = ex;
+ unhandledTcs.TrySetResult();
+ });
+
+ var subject = SubjectAsync.Create();
+ var throwingObserver = new ThrowingAsyncObserver(new InvalidOperationException("debounce-downstream-throws"));
+
+ await using var sub = await subject.Values
+ .DebounceUntil(TimeSpan.FromMilliseconds(ThrottleWindowMilliseconds), static _ => false)
+ .SubscribeAsync(throwingObserver, CancellationToken.None);
+
+ await subject.OnNextAsync(One, CancellationToken.None);
+ await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(unhandled).IsNotNull();
+ await Assert.That(unhandled!.Message).IsEqualTo("debounce-downstream-throws");
+ }
+ finally
+ {
+ UnhandledExceptionHandler.Register(previousHandler);
+ }
+ }
+
+ /// Verifies that Partition drops upstream values whose predicate matches a
+ /// branch that has no current subscriber — exercises the
+ /// target?.OnNextAsync(...) ?? default null-target path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenPartitionEmitsWhileMatchingBranchUnsubscribed_ThenDropped()
+ {
+ var subject = SubjectAsync.Create();
+ var (evens, _) = subject.Values.Partition(static x => x % Two == 0);
+
+ var values = new List();
+ await using var sub = await evens.SubscribeAsync((v, _) =>
+ {
+ values.Add(v);
+ return default;
+ });
+
+ // Odd value: matches the false branch, which has no subscriber.
+ await subject.OnNextAsync(One, CancellationToken.None);
+
+ // Even value: matches the true branch.
+ await subject.OnNextAsync(Two, CancellationToken.None);
+
+ await Assert.That(values).IsCollectionEqualTo([Two]);
+ }
+
+ /// Verifies that
+ /// returns when the id has been superseded by a newer upstream emission.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenThrottleDistinctTryClaimEmissionSuperseded_ThenReturnsFalse()
+ {
+ var observer = new ObservableAsync.ThrottleDistinctObservable.ThrottleDistinctObserver(
+ new NoOpAsyncObserver(),
+ TimeSpan.FromHours(1),
+ TimeProvider.System,
+ CancellationToken.None);
+
+ // Drive _id forward by two emissions; the first pending delay's id (1) is then stale.
+ await observer.OnNextAsync(One, CancellationToken.None);
+ await observer.OnNextAsync(Two, CancellationToken.None);
+
+ var claimed = observer.TryClaimEmission(One, id: 1);
+
+ await Assert.That(claimed).IsFalse();
+ }
+
+ /// Verifies that
+ /// returns when the value is a duplicate of the most-recently-emitted one.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenThrottleDistinctTryClaimEmissionDuplicate_ThenReturnsFalse()
+ {
+ var observer = new ObservableAsync.ThrottleDistinctObservable.ThrottleDistinctObserver(
+ new NoOpAsyncObserver(),
+ TimeSpan.FromHours(1),
+ TimeProvider.System,
+ CancellationToken.None);
+
+ await observer.OnNextAsync(One, CancellationToken.None);
+
+ // First claim latches the downstream-distinct state with value One.
+ var firstClaim = observer.TryClaimEmission(One, id: 1);
+
+ // Drive another upstream so id matches the second claim.
+ await observer.OnNextAsync(Two, CancellationToken.None);
+
+ // Re-claim with the previously-emitted value at the new id — rejected by the
+ // downstream-distinct check.
+ var secondClaim = observer.TryClaimEmission(One, id: 2);
+
+ await Assert.That(firstClaim).IsTrue();
+ await Assert.That(secondClaim).IsFalse();
+ }
+
+ /// Verifies that
+ /// returns for the most-recent id and for
+ /// stale ids.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDebounceUntilIsCurrentEmission_ThenMatchesIdState()
+ {
+ var observer = new ObservableAsync.DebounceUntilObservable.DebounceUntilObserver(
+ new NoOpAsyncObserver(),
+ TimeSpan.FromHours(1),
+ static _ => false,
+ TimeProvider.System,
+ CancellationToken.None);
+
+ await observer.OnNextAsync(One, CancellationToken.None);
+ await observer.OnNextAsync(Two, CancellationToken.None);
+
+ await Assert.That(observer.IsCurrentEmission(id: 2)).IsTrue();
+ await Assert.That(observer.IsCurrentEmission(id: 1)).IsFalse();
+ }
+
+ /// Verifies that
+ /// returns when both branches have already been disposed by the time
+ /// the source subscription returns — the disposeNow race fast-path that is otherwise only
+ /// reachable through a real concurrency race.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenPartitionTryAttachSourceSubscriptionAndBothBranchesGone_ThenReturnsFalse()
+ {
+ var subject = SubjectAsync.Create();
+ var coordinator = new ObservableAsync.PartitionCoordinator(subject.Values, static x => x % Two == 0);
+
+ // Subscribe then immediately dispose so both branch slots are null.
+ var sub = await coordinator.TrueBranch.SubscribeAsync(static (_, _) => default);
+ await sub.DisposeAsync();
+
+ var attached = coordinator.TryAttachSourceSubscription(DisposableAsync.Empty);
+
+ await Assert.That(attached).IsFalse();
+ }
+
+ /// Verifies that
+ /// returns when at least one branch is still alive.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenPartitionTryAttachSourceSubscriptionAndBranchAlive_ThenReturnsTrue()
+ {
+ var subject = SubjectAsync.Create();
+ var coordinator = new ObservableAsync.PartitionCoordinator(subject.Values, static x => x % Two == 0);
+
+ await using var sub = await coordinator.TrueBranch.SubscribeAsync(static (_, _) => default);
+
+ var attached = coordinator.TryAttachSourceSubscription(DisposableAsync.Empty);
+
+ await Assert.That(attached).IsTrue();
+ }
+
+ /// Yields values as a generic (neither array nor list)
+ /// to drive the slow-path branch of ForEach.
+ /// Values to yield.
+ /// A lazily-evaluated enumerable.
+ private static IEnumerable Enumerate(params int[] values)
+ {
+ foreach (var v in values)
+ {
+ yield return v;
+ }
+ }
+
+ /// Bare-bones downstream async observer that throws a given exception inside
+ /// OnNextAsync. Bypassing the base class is intentional
+ /// — the base class would otherwise swallow synchronous throws and route them through
+ /// , never letting the exception propagate up to the
+ /// upstream operator's catch (Exception e) block under test.
+ /// The element type.
+ /// The exception to throw on every emission.
+ private sealed class ThrowingAsyncObserver(Exception error) : IObserverAsync
+ {
+ ///
+ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) =>
+ throw error;
+
+ ///
+ public ValueTask OnErrorResumeAsync(Exception err, CancellationToken cancellationToken) =>
+ default;
+
+ ///
+ public ValueTask OnCompletedAsync(Result result) => default;
+
+ ///
+ public ValueTask DisposeAsync() => default;
+ }
+
+ /// No-op async observer used as a downstream stand-in for direct unit tests of
+ /// observer-internal decision methods.
+ /// The element type.
+ private sealed class NoOpAsyncObserver : IObserverAsync
+ {
+ ///
+ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) => default;
+
+ ///
+ public ValueTask OnErrorResumeAsync(Exception error, CancellationToken cancellationToken) => default;
+
+ ///
+ public ValueTask OnCompletedAsync(Result result) => default;
+
+ ///
+ public ValueTask DisposeAsync() => default;
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs
index a8fe24e..0a5d638 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/ResultAndInfrastructureTests.cs
@@ -287,6 +287,60 @@ await SubscriptionHelper.SubscribeAndDisposeOnFailureAsync(
await Assert.That(disposed).IsTrue();
}
+ /// Verifies that a synchronous throw from OnErrorResumeAsyncCore is caught
+ /// by and routed to
+ /// .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenObserverOnErrorResumeCoreThrowsSync_ThenRoutedToUnhandled()
+ {
+ Exception? unhandled = null;
+ var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ UnhandledExceptionHandler.Register(ex =>
+ {
+ unhandled = ex;
+ unhandledTcs.TrySetResult();
+ });
+
+ var observer = new TestableObserverAsync(
+ onErrorResumeAsyncCore: static (_, _) => throw new InvalidOperationException("sync-throw"));
+
+ await observer.OnErrorResumeAsync(new InvalidOperationException("original"), CancellationToken.None);
+
+ await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(unhandled).IsNotNull();
+ await Assert.That(unhandled!.Message).IsEqualTo("sync-throw");
+ }
+
+ /// Verifies that an asynchronous throw from OnCompletedAsyncCore is caught
+ /// by 's slow path and routed to
+ /// .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenObserverOnCompletedCoreThrowsAsync_ThenRoutedToUnhandled()
+ {
+ Exception? unhandled = null;
+ var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ UnhandledExceptionHandler.Register(ex =>
+ {
+ unhandled = ex;
+ unhandledTcs.TrySetResult();
+ });
+
+ var observer = new TestableObserverAsync(
+ onCompletedAsyncCore: static async _ =>
+ {
+ await Task.Yield();
+ throw new InvalidOperationException("async-throw");
+ });
+
+ await observer.OnCompletedAsync(Result.Success);
+
+ await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(unhandled).IsNotNull();
+ await Assert.That(unhandled!.Message).IsEqualTo("async-throw");
+ }
+
///
/// A concrete implementation for testing, with
/// configurable behavior for each virtual method.
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs
index 349dd2d..caffef6 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/SubjectTests.BehaviorAndReplay.cs
@@ -685,4 +685,61 @@ public async Task WhenSubscribeToCompletedReplayLatest_ThenObserverReceivesImmed
await Assert.That(lateResult).IsNotNull();
await Assert.That(lateResult!.Value.IsSuccess).IsTrue();
}
+
+ /// Verifies that the replay-latest subject's OnNextAsync with a caller-supplied
+ /// cancellation token takes the linked-CTS slow path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenReplayLatestOnNextWithCustomToken_ThenForwardsValue()
+ {
+ var subject = SubjectAsync.CreateReplayLatest(new ReplayLatestSubjectCreationOptions
+ {
+ PublishingOption = PublishingOption.Concurrent,
+ IsStateless = false,
+ });
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.SubscribeAsync(
+ (v, _) =>
+ {
+ tcs.TrySetResult(v);
+ return default;
+ });
+
+ using var cts = new CancellationTokenSource();
+ const int LinkedCtsValue = 11;
+ await subject.OnNextAsync(LinkedCtsValue, cts.Token);
+
+ var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(received).IsEqualTo(LinkedCtsValue);
+ }
+
+ /// Verifies that the replay-latest subject's OnErrorResumeAsync with a
+ /// caller-supplied cancellation token takes the linked-CTS slow path.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenReplayLatestOnErrorResumeWithCustomToken_ThenForwardsError()
+ {
+ var subject = SubjectAsync.CreateReplayLatest(new ReplayLatestSubjectCreationOptions
+ {
+ PublishingOption = PublishingOption.Concurrent,
+ IsStateless = false,
+ });
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values.SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ tcs.TrySetResult(ex);
+ return default;
+ });
+
+ var expected = new InvalidOperationException("linked-cts");
+ using var cts = new CancellationTokenSource();
+ await subject.OnErrorResumeAsync(expected, cts.Token);
+
+ var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(received).IsSameReferenceAs(expected);
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs
index 247c136..54ca3a1 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TakeUntilOperatorTests.cs
@@ -703,4 +703,100 @@ public async Task WhenTakeUntilCompletionDelegateSuccess_ThenCompletesSequence()
await Assert.That(completionResult).IsNotNull();
await Assert.That(completionResult!.Value.IsSuccess).IsTrue();
}
+
+ /// Verifies the two-argument TakeUntil(other, cancellationToken) overload.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTakeUntilOtherWithCancellationToken_ThenCompletesOnCancellation()
+ {
+ using var cts = new CancellationTokenSource();
+ var source = SubjectAsync.Create();
+ var other = SubjectAsync.Create();
+ var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await source.Values.TakeUntil(other.Values, cts.Token).SubscribeAsync(
+ static (_, _) => default,
+ null,
+ _ =>
+ {
+ completed.TrySetResult();
+ return default;
+ });
+
+ await cts.CancelAsync();
+ await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ /// Verifies the two-argument TakeUntil(task, cancellationToken) overload.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTakeUntilTaskWithCancellationToken_ThenCompletesOnCancellation()
+ {
+ using var cts = new CancellationTokenSource();
+ var source = SubjectAsync.Create();
+ var taskTcs = new TaskCompletionSource();
+ var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await source.Values.TakeUntil(taskTcs.Task, cts.Token).SubscribeAsync(
+ static (_, _) => default,
+ null,
+ _ =>
+ {
+ completed.TrySetResult();
+ return default;
+ });
+
+ await cts.CancelAsync();
+ await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ /// Verifies the predicate overload with a cancellable token reaches the
+ /// CT-linked branch.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTakeUntilPredicateWithCancellationToken_ThenCompletesOnCancellation()
+ {
+ using var cts = new CancellationTokenSource();
+ var source = SubjectAsync.Create();
+ var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await source.Values
+ .TakeUntil(static _ => false, cts.Token)
+ .SubscribeAsync(
+ static (_, _) => default,
+ null,
+ _ =>
+ {
+ completed.TrySetResult();
+ return default;
+ });
+
+ await cts.CancelAsync();
+ await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ }
+
+ /// Verifies the async-predicate overload with a cancellable token reaches the
+ /// CT-linked branch.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTakeUntilAsyncPredicateWithCancellationToken_ThenCompletesOnCancellation()
+ {
+ using var cts = new CancellationTokenSource();
+ var source = SubjectAsync.Create();
+ var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await source.Values
+ .TakeUntil(static (_, _) => new ValueTask(false), cts.Token)
+ .SubscribeAsync(
+ static (_, _) => default,
+ null,
+ _ =>
+ {
+ completed.TrySetResult();
+ return default;
+ });
+
+ await cts.CancelAsync();
+ await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs
index ceb5f3b..d77b934 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TerminalOperatorTests.cs
@@ -1317,6 +1317,26 @@ public async Task WhenContainsAsyncValueNotFound_ThenReturnsFalse()
await Assert.That(result).IsFalse();
}
+ /// Exercises the ContainsAsync(value, comparer) overload — the no-cancellation
+ /// shortcut that forwards to the full overload with .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenContainsAsyncWithComparerOverload_ThenForwardsResult()
+ {
+ var result = await ObservableAsync.Range(1, 3).ContainsAsync(2, EqualityComparer.Default);
+ await Assert.That(result).IsTrue();
+ }
+
+ /// Exercises the ContainsAsync(value, cancellationToken) overload — the
+ /// no-comparer shortcut that forwards to the full overload with a null comparer.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenContainsAsyncWithCancellationTokenOverload_ThenForwardsResult()
+ {
+ var result = await ObservableAsync.Range(1, 3).ContainsAsync(2, CancellationToken.None);
+ await Assert.That(result).IsTrue();
+ }
+
/// Tests CountAsync with predicate that filters some elements.
/// A representing the asynchronous test operation.
[Test]
@@ -1398,4 +1418,12 @@ await Assert.ThrowsAsync(async () =>
[Test]
public void WhenWrapWithNullObserver_ThenThrowsArgumentNullException() =>
Assert.Throws(() => ObservableAsync.Wrap(null!));
+
+ /// Verifies the async-callback ForEachAsync overload throws when the
+ /// callback delegate is null.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenForEachAsyncCallbackNull_ThenThrowsArgumentNullException() =>
+ await Assert.ThrowsAsync(async () =>
+ await ObservableAsync.Return(1).ForEachAsync(null!, CancellationToken.None));
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs
index 4de9bcf..40b8286 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TimeBasedOperatorTests.cs
@@ -924,6 +924,40 @@ public async Task WhenTimeoutResetsOnValue_ThenDoesNotFire()
await Assert.That(result).IsCollectionEqualTo([1, ExpectedSecond, ExpectedThird]);
}
+ /// Verifies that an exception thrown by the downstream observer's
+ /// OnCompletedAsync during a Timeout firing is routed to
+ /// .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTimeoutFiresAndDownstreamCompletionThrows_ThenRoutedToUnhandled()
+ {
+ var previousHandler = UnhandledExceptionHandler.CurrentHandler;
+ try
+ {
+ Exception? unhandled = null;
+ var unhandledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ UnhandledExceptionHandler.Register(ex =>
+ {
+ unhandled = ex;
+ unhandledTcs.TrySetResult();
+ });
+
+ var throwing = new TimeoutThrowingObserver(new InvalidOperationException("completion-failed"));
+
+ await using var sub = await ObservableAsync.Never()
+ .Timeout(TimeSpan.FromMilliseconds(1))
+ .SubscribeAsync(throwing, CancellationToken.None);
+
+ await unhandledTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(unhandled).IsNotNull();
+ await Assert.That(unhandled!.Message).IsEqualTo("completion-failed");
+ }
+ finally
+ {
+ UnhandledExceptionHandler.Register(previousHandler);
+ }
+ }
+
///
/// A custom that delegates timer creation to the system provider.
/// Used to exercise the non-system code paths in Interval and Timer operators.
@@ -1010,4 +1044,23 @@ public void Dispose()
public ValueTask DisposeAsync() => default;
}
}
+
+ /// Bare-bones downstream observer that throws from OnCompletedAsync to
+ /// exercise the catch block in Timeout's FireTimeoutAsync.
+ /// The element type.
+ /// The exception to throw on completion.
+ private sealed class TimeoutThrowingObserver(Exception error) : IObserverAsync
+ {
+ ///
+ public ValueTask OnNextAsync(T value, CancellationToken cancellationToken) => default;
+
+ ///
+ public ValueTask OnErrorResumeAsync(Exception err, CancellationToken cancellationToken) => default;
+
+ ///
+ public ValueTask OnCompletedAsync(Result result) => throw error;
+
+ ///
+ public ValueTask DisposeAsync() => default;
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs
index ba391e2..8833ed5 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Async/TransformationOperatorTests.cs
@@ -14,6 +14,9 @@ namespace ReactiveUI.Extensions.Tests.Async;
///
public class TransformationOperatorTests
{
+ /// Number of inputs fed into the async-accumulator Scan sync-result test.
+ private const int ScanInputCount = 3;
+
/// Hoisted source array used by tests (was inline literal).
private static readonly int[] Sequence123456 = [1, 2, 3, 4, 5, 6];
@@ -1013,6 +1016,78 @@ await ObservableAsync.Using(
await Assert.That(disposed).IsTrue();
}
+ /// Verifies the async-accumulator Scan overload's sync-completed fast path —
+ /// returning a synchronously-completed from the accumulator
+ /// takes the inline pending.Result branch.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenScanAsyncAccumulatorReturnsSync_ThenForwardsAccumulator()
+ {
+ const int ThirdRunningTotal = 3;
+ const int SixthRunningTotal = 6;
+ var result = await ObservableAsync.Range(1, ScanInputCount)
+ .Scan(0, static (acc, x, _) => new ValueTask(acc + x))
+ .ToListAsync();
+
+ await Assert.That(result).IsCollectionEqualTo([1, ThirdRunningTotal, SixthRunningTotal]);
+ }
+
+ /// Verifies that the sync-accumulator Scan overload forwards a non-terminal
+ /// upstream error downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenScanSyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .Scan(0, static (acc, x) => acc + x)
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("scan-sync-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that the async-accumulator Scan overload forwards a non-terminal
+ /// upstream error downstream.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenScanAsyncSourceErrorResume_ThenForwarded()
+ {
+ var subject = SubjectAsync.Create();
+ Exception? caught = null;
+ var errorTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var sub = await subject.Values
+ .Scan(0, static (acc, x, _) => new ValueTask(acc + x))
+ .SubscribeAsync(
+ static (_, _) => default,
+ (ex, _) =>
+ {
+ caught = ex;
+ errorTcs.TrySetResult();
+ return default;
+ });
+
+ var expected = new InvalidOperationException("scan-async-error");
+ await subject.OnErrorResumeAsync(expected, CancellationToken.None);
+
+ await errorTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await Assert.That(caught).IsSameReferenceAs(expected);
+ }
+
///
/// A raw implementation that throws a specified exception
/// from . Unlike , this
diff --git a/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs b/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs
index a8fbd78..e5b1c60 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/CurrentValueSubjectTests.MultiObserver.cs
@@ -185,4 +185,44 @@ public async Task WhenMultipleObserversAndOnError_ThenAllReceiveError()
await Assert.That(errB).IsSameReferenceAs(expected);
await Assert.That(errC).IsSameReferenceAs(expected);
}
+
+ /// Verifies that OnCompleted broadcasts to multiple observers and a
+ /// subsequent OnCompleted is a no-op.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenMultipleObserversAndOnCompleted_ThenAllReceiveCompletionAndSecondIsNoOp()
+ {
+ using var subject = new CurrentValueSubject(MultiInitialValue);
+ var completedA = 0;
+ var completedB = 0;
+ var completedC = 0;
+
+ using var subA = subject.Subscribe(static _ => { }, () => completedA++);
+ using var subB = subject.Subscribe(static _ => { }, () => completedB++);
+ using var subC = subject.Subscribe(static _ => { }, () => completedC++);
+
+ subject.OnCompleted();
+ subject.OnCompleted();
+
+ await Assert.That(completedA).IsEqualTo(1);
+ await Assert.That(completedB).IsEqualTo(1);
+ await Assert.That(completedC).IsEqualTo(1);
+ }
+
+ /// Verifies that disposing the same subscription twice is idempotent.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSubscriptionDisposedTwice_ThenIdempotent()
+ {
+ using var subject = new CurrentValueSubject(MultiInitialValue);
+ var values = new List();
+
+ var sub = subject.Subscribe(values.Add);
+ sub.Dispose();
+ sub.Dispose();
+
+ subject.OnNext(MultiInitialValue + 1);
+
+ await Assert.That(values).IsCollectionEqualTo([MultiInitialValue]);
+ }
}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/ActionDisposableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/ActionDisposableTests.cs
new file mode 100644
index 0000000..b45679f
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/ActionDisposableTests.cs
@@ -0,0 +1,27 @@
+// 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.Disposables;
+
+namespace ReactiveUI.Extensions.Tests.Internal;
+
+/// Tests for — verifies the dispose action runs exactly
+/// once regardless of how many times Dispose is called.
+public class ActionDisposableTests
+{
+ /// Verifies the action is invoked exactly once across repeated Dispose calls.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenActionDisposableDisposedTwice_ThenActionInvokedExactlyOnce()
+ {
+ var invocations = 0;
+ var disposable = new ActionDisposable(() => invocations++);
+
+ disposable.Dispose();
+ disposable.Dispose();
+ disposable.Dispose();
+
+ await Assert.That(invocations).IsEqualTo(1);
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs
new file mode 100644
index 0000000..2ee9588
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/ConcurrencyRaceHelpersTests.cs
@@ -0,0 +1,75 @@
+// 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;
+
+/// Direct unit tests for — both race-claim
+/// primitives are pure functions over their inputs and every branch is exercised here.
+public class ConcurrencyRaceHelpersTests
+{
+ /// Sentinel for the "not yet claimed" state in the tests.
+ private const int Open = 0;
+
+ /// Sentinel for the "claimed" state in the tests.
+ private const int Claimed = 1;
+
+ /// Verifies succeeds when the state
+ /// is open and transitions it to the claimed sentinel.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTryClaimOpen_ThenReturnsTrueAndTransitions()
+ {
+ var state = Open;
+
+ var claimed = ConcurrencyRaceHelpers.TryClaim(ref state, Open, Claimed);
+
+ await Assert.That(claimed).IsTrue();
+ await Assert.That(state).IsEqualTo(Claimed);
+ }
+
+ /// Verifies returns false when the
+ /// state is already claimed and does not mutate it further.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTryClaimAlreadyClaimed_ThenReturnsFalse()
+ {
+ var state = Claimed;
+
+ var claimed = ConcurrencyRaceHelpers.TryClaim(ref state, Open, Claimed);
+
+ await Assert.That(claimed).IsFalse();
+ await Assert.That(state).IsEqualTo(Claimed);
+ }
+
+ /// Verifies returns
+ /// when called on an open CTS and the cancellation goes through.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTryCancelOpenCts_ThenReturnsTrueAndTokenCancels()
+ {
+ var cts = new CancellationTokenSource();
+
+ var succeeded = await ConcurrencyRaceHelpers.TryCancelAsync(cts);
+
+ await Assert.That(succeeded).IsTrue();
+ await Assert.That(cts.IsCancellationRequested).IsTrue();
+ }
+
+ /// Verifies returns
+ /// when called on a disposed CTS and silently swallows the
+ /// .
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTryCancelDisposedCts_ThenReturnsFalseAndSwallowsObjectDisposed()
+ {
+ var cts = new CancellationTokenSource();
+ cts.Dispose();
+
+ var succeeded = await ConcurrencyRaceHelpers.TryCancelAsync(cts);
+
+ await Assert.That(succeeded).IsFalse();
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/DelegateObserverTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/DelegateObserverTests.cs
new file mode 100644
index 0000000..af591dd
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/DelegateObserverTests.cs
@@ -0,0 +1,65 @@
+// 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 — verifies the null-callback branches
+/// for OnError and OnCompleted as well as the all-callbacks-supplied happy path.
+public class DelegateObserverTests
+{
+ /// Sentinel value emitted in single-value cases.
+ private const int Sentinel = 42;
+
+ /// Verifies that omitting the error callback still allows OnError to be invoked safely.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenErrorCallbackNull_ThenOnErrorIsNoOp()
+ {
+ var values = new List();
+ var observer = new DelegateObserver(values.Add);
+
+ observer.OnNext(1);
+ observer.OnError(new InvalidOperationException("ignored"));
+
+ await Assert.That(values).IsCollectionEqualTo([1]);
+ }
+
+ /// Verifies that omitting the completion callback still allows OnCompleted to be invoked safely.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenCompletedCallbackNull_ThenOnCompletedIsNoOp()
+ {
+ var values = new List();
+ var observer = new DelegateObserver(values.Add);
+
+ observer.OnNext(Sentinel);
+ observer.OnCompleted();
+
+ await Assert.That(values).IsCollectionEqualTo([Sentinel]);
+ }
+
+ /// Verifies all three callbacks fire when supplied.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenAllCallbacksSupplied_ThenEachInvoked()
+ {
+ var values = new List();
+ Exception? caught = null;
+ var completed = false;
+ var observer = new DelegateObserver(
+ values.Add,
+ ex => caught = ex,
+ () => completed = true);
+
+ observer.OnNext(1);
+ observer.OnError(new InvalidOperationException("boom"));
+ observer.OnCompleted();
+
+ await Assert.That(values).IsCollectionEqualTo([1]);
+ await Assert.That(caught).IsTypeOf();
+ await Assert.That(completed).IsTrue();
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/DisposableSlotHelperTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/DisposableSlotHelperTests.cs
new file mode 100644
index 0000000..1d60155
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/DisposableSlotHelperTests.cs
@@ -0,0 +1,119 @@
+// 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.Disposables;
+
+namespace ReactiveUI.Extensions.Tests.Internal;
+
+/// Direct unit tests for . Covers every reachable
+/// branch — the already-disposed pre-check, the steady-state assign, the swap-disposes-previous
+/// path, and the idempotent TryDispose latch. The single race-recheck step that fires
+/// only under a real concurrent dispose is isolated in DisposeIfRaced and excluded from
+/// coverage there.
+public class DisposableSlotHelperTests
+{
+ /// Verifies that an incoming value is disposed immediately if the slot is already disposed.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenAssignWithoutDisposingPreviousIntoDisposedSlot_ThenIncomingDisposed()
+ {
+ IDisposable? slot = null;
+ var disposed = DisposableSlotHelper.DisposedSentinel;
+ var late = new CountingDisposable();
+
+ DisposableSlotHelper.AssignWithoutDisposingPrevious(ref slot, ref disposed, late);
+
+ await Assert.That(late.DisposeCount).IsEqualTo(1);
+ await Assert.That(slot).IsNull();
+ }
+
+ /// Verifies the steady-state assign — slot transitions to the new value without disposing the previous.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenAssignWithoutDisposingPreviousOpen_ThenStoresAndLeavesPreviousAlone()
+ {
+ var first = new CountingDisposable();
+ IDisposable? slot = first;
+ var disposed = 0;
+ var second = new CountingDisposable();
+
+ DisposableSlotHelper.AssignWithoutDisposingPrevious(ref slot, ref disposed, second);
+
+ await Assert.That(slot).IsSameReferenceAs(second);
+ await Assert.That(first.DisposeCount).IsEqualTo(0);
+ }
+
+ /// Verifies that assigning a null value into an open slot stores null without throwing.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenAssignWithoutDisposingPreviousNullValueOpen_ThenStoresNull()
+ {
+ var first = new CountingDisposable();
+ IDisposable? slot = first;
+ var disposed = 0;
+
+ DisposableSlotHelper.AssignWithoutDisposingPrevious(ref slot, ref disposed, null);
+
+ await Assert.That(slot).IsNull();
+ await Assert.That(first.DisposeCount).IsEqualTo(0);
+ }
+
+ /// Verifies the swap path disposes the previous value on each assignment.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSwapAndDisposePreviousOpen_ThenPreviousDisposed()
+ {
+ var first = new CountingDisposable();
+ IDisposable? slot = first;
+ var disposed = 0;
+ var second = new CountingDisposable();
+
+ DisposableSlotHelper.SwapAndDisposePrevious(ref slot, ref disposed, second);
+
+ await Assert.That(slot).IsSameReferenceAs(second);
+ await Assert.That(first.DisposeCount).IsEqualTo(1);
+ await Assert.That(second.DisposeCount).IsEqualTo(0);
+ }
+
+ /// Verifies the swap path disposes the incoming value if the slot is already disposed.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSwapAndDisposePreviousIntoDisposedSlot_ThenIncomingDisposed()
+ {
+ IDisposable? slot = null;
+ var disposed = DisposableSlotHelper.DisposedSentinel;
+ var late = new CountingDisposable();
+
+ DisposableSlotHelper.SwapAndDisposePrevious(ref slot, ref disposed, late);
+
+ await Assert.That(late.DisposeCount).IsEqualTo(1);
+ }
+
+ /// Verifies TryDispose latches and disposes the inner on the first call.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenTryDisposeOpen_ThenLatchesAndDisposesInner()
+ {
+ var inner = new CountingDisposable();
+ IDisposable? slot = inner;
+ var disposed = 0;
+
+ var first = DisposableSlotHelper.TryDispose(ref slot, ref disposed);
+ var second = DisposableSlotHelper.TryDispose(ref slot, ref disposed);
+
+ await Assert.That(first).IsTrue();
+ await Assert.That(second).IsFalse();
+ await Assert.That(inner.DisposeCount).IsEqualTo(1);
+ }
+
+ /// Disposable used to verify dispose counts.
+ private sealed class CountingDisposable : IDisposable
+ {
+ /// Gets the number of times has been invoked.
+ public int DisposeCount { get; private set; }
+
+ ///
+ public void Dispose() => DisposeCount++;
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs
index cc2eb37..f7d5585 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/FirstAsTaskHelperTests.cs
@@ -52,4 +52,95 @@ public async Task WhenSourceEmitsMultiple_ThenTaskCompletesWithFirst()
await Assert.That(await task).IsEqualTo(FirstValue);
}
+
+ /// Verifies the helper throws when the source argument is null.
+ [Test]
+ public void WhenSourceNull_ThenThrowsArgumentNullException() =>
+ Assert.Throws(static () => FirstAsTaskHelper.FirstAsTask(null!));
+
+ /// Verifies emissions arriving after the task has already settled are silently ignored.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSubjectErrorsThenLaterEvents_ThenLaterEventsIgnored()
+ {
+ var subject = new Subject();
+ var task = FirstAsTaskHelper.FirstAsTask(subject);
+ var expected = new InvalidOperationException("first");
+
+ subject.OnError(expected);
+ subject.OnCompleted();
+ subject.OnNext(FirstValue);
+
+ var ex = await Assert.ThrowsAsync(async () => await task);
+ await Assert.That(ex).IsSameReferenceAs(expected);
+ }
+
+ /// Verifies that a second OnNext arriving via a non-cooperative source
+ /// (one that does not stop emitting after the first value) is dropped by the latch.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSecondOnNextAfterFirstSettled_ThenIgnored()
+ {
+ var source = new InvasiveObservable();
+ var task = FirstAsTaskHelper.FirstAsTask(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 that a second OnError arriving after the first is dropped.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSecondOnErrorAfterFirstSettled_ThenIgnored()
+ {
+ var source = new InvasiveObservable();
+ var task = FirstAsTaskHelper.FirstAsTask(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 that a second OnCompleted arriving after the first is dropped.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSecondOnCompletedAfterFirstSettled_ThenIgnored()
+ {
+ var source = new InvasiveObservable();
+ var task = FirstAsTaskHelper.FirstAsTask(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 FirstAsTaskHelper's 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/Internal/MutableDisposableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/MutableDisposableTests.cs
new file mode 100644
index 0000000..fec77a5
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/MutableDisposableTests.cs
@@ -0,0 +1,74 @@
+// 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.Disposables;
+
+namespace ReactiveUI.Extensions.Tests.Internal;
+
+/// Tests for — verifies that reassigning the inner does
+/// NOT dispose the previous, that assigning after disposal immediately disposes the incoming
+/// value, and that Dispose is idempotent.
+public class MutableDisposableTests
+{
+ /// Verifies replacement leaves the previous inner alone.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenInnerReplaced_ThenPreviousIsNotDisposed()
+ {
+ using var holder = new MutableDisposable();
+ var firstDisposed = 0;
+ var secondDisposed = 0;
+ var first = new ActionDisposable(() => firstDisposed++);
+ var second = new ActionDisposable(() => secondDisposed++);
+
+ holder.Disposable = first;
+ holder.Disposable = second;
+
+ await Assert.That(firstDisposed).IsEqualTo(0);
+ await Assert.That(holder.Disposable).IsSameReferenceAs(second);
+ await Assert.That(secondDisposed).IsEqualTo(0);
+ }
+
+ /// Verifies that assigning after disposal immediately disposes the incoming value.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSetAfterDispose_ThenIncomingIsDisposedImmediately()
+ {
+ var holder = new MutableDisposable();
+ holder.Dispose();
+ var late = 0;
+
+ holder.Disposable = new ActionDisposable(() => late++);
+
+ await Assert.That(late).IsEqualTo(1);
+ }
+
+ /// Verifies that assigning after disposal is a no-op.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSetNullAfterDispose_ThenNoThrow()
+ {
+ var holder = new MutableDisposable();
+ holder.Dispose();
+
+ holder.Disposable = null;
+
+ await Assert.That(holder.Disposable).IsNull();
+ }
+
+ /// Verifies Dispose disposes the inner and is idempotent across repeated calls.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDisposedTwice_ThenInnerDisposedOnce()
+ {
+ var holder = new MutableDisposable();
+ var disposed = 0;
+ holder.Disposable = new ActionDisposable(() => disposed++);
+
+ holder.Dispose();
+ holder.Dispose();
+
+ await Assert.That(disposed).IsEqualTo(1);
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs
new file mode 100644
index 0000000..b4f9025
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/ObserverArrayHelpersTests.cs
@@ -0,0 +1,163 @@
+// 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;
+
+/// Direct unit tests for — both the broadcast
+/// loop and the remove-or-null short-circuit paths. The helpers are pure functions over
+/// their inputs, so each branch is exercised by passing synthesized arrays rather than
+/// relying on operator-level scheduler races.
+public class ObserverArrayHelpersTests
+{
+ /// Sentinel value broadcast through the helper.
+ private const int Sentinel = 7;
+
+ /// Expected length of the array after removing one observer from three.
+ private const int RemainingLengthAfterRemoveFromThree = 2;
+
+ /// Verifies short-circuits when
+ /// the observer array is empty.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenBroadcastEmpty_ThenNoOp()
+ {
+ var observers = Array.Empty>();
+
+ ObserverArrayHelpers.Broadcast(observers, Sentinel);
+
+ await Assert.That(observers).IsEmpty();
+ }
+
+ /// Verifies fans the value out to
+ /// every observer in order.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenBroadcastMultiple_ThenEveryObserverReceivesValue()
+ {
+ var first = new RecordingObserver();
+ var second = new RecordingObserver();
+ var third = new RecordingObserver();
+ IObserver[] observers = [first, second, third];
+
+ ObserverArrayHelpers.Broadcast(observers, Sentinel);
+
+ await Assert.That(first.Values).IsCollectionEqualTo([Sentinel]);
+ await Assert.That(second.Values).IsCollectionEqualTo([Sentinel]);
+ await Assert.That(third.Values).IsCollectionEqualTo([Sentinel]);
+ }
+
+ /// Verifies returns
+ /// when the observer is not present in the array.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenRemoveNotPresent_ThenReturnsNull()
+ {
+ var empty = Array.Empty>();
+ var resident = new RecordingObserver();
+ var stranger = new RecordingObserver();
+ IObserver[] current = [resident];
+
+ var result = ObserverArrayHelpers.RemoveOrNull(current, stranger, empty);
+
+ await Assert.That(result).IsNull();
+ }
+
+ /// Verifies returns the empty
+ /// sentinel when the array contains exactly one observer (the one being removed).
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenRemoveSingleton_ThenReturnsEmptySentinel()
+ {
+ var empty = Array.Empty>();
+ var only = new RecordingObserver();
+ IObserver[] current = [only];
+
+ var result = ObserverArrayHelpers.RemoveOrNull(current, only, empty);
+
+ await Assert.That(result).IsSameReferenceAs(empty);
+ }
+
+ /// Verifies removes the first
+ /// observer from a multi-element array (no left copy, full right copy).
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenRemoveFirstFromThree_ThenLeavesTrailingPair()
+ {
+ var empty = Array.Empty>();
+ var a = new RecordingObserver();
+ var b = new RecordingObserver();
+ var c = new RecordingObserver();
+ IObserver[] current = [a, b, c];
+
+ var result = ObserverArrayHelpers.RemoveOrNull(current, a, empty);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Length).IsEqualTo(RemainingLengthAfterRemoveFromThree);
+ await Assert.That(ReferenceEquals(result[0], b)).IsTrue();
+ await Assert.That(ReferenceEquals(result[1], c)).IsTrue();
+ }
+
+ /// Verifies removes the middle
+ /// observer (both left and right copies non-empty).
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenRemoveMiddleFromThree_ThenLeavesFirstAndLast()
+ {
+ var empty = Array.Empty>();
+ var a = new RecordingObserver();
+ var b = new RecordingObserver();
+ var c = new RecordingObserver();
+ IObserver[] current = [a, b, c];
+
+ var result = ObserverArrayHelpers.RemoveOrNull(current, b, empty);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Length).IsEqualTo(RemainingLengthAfterRemoveFromThree);
+ await Assert.That(ReferenceEquals(result[0], a)).IsTrue();
+ await Assert.That(ReferenceEquals(result[1], c)).IsTrue();
+ }
+
+ /// Verifies removes the last
+ /// observer (full left copy, no right copy).
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenRemoveLastFromThree_ThenLeavesLeadingPair()
+ {
+ var empty = Array.Empty>();
+ var a = new RecordingObserver();
+ var b = new RecordingObserver();
+ var c = new RecordingObserver();
+ IObserver[] current = [a, b, c];
+
+ var result = ObserverArrayHelpers.RemoveOrNull(current, c, empty);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Length).IsEqualTo(RemainingLengthAfterRemoveFromThree);
+ await Assert.That(ReferenceEquals(result[0], a)).IsTrue();
+ await Assert.That(ReferenceEquals(result[1], b)).IsTrue();
+ }
+
+ /// Recording observer used to verify Broadcast reaches each slot.
+ /// The element type.
+ private sealed class RecordingObserver : IObserver
+ {
+ /// Gets the captured OnNext values in order.
+ public List Values { get; } = [];
+
+ ///
+ public void OnNext(T value) => Values.Add(value);
+
+ ///
+ public void OnError(Exception error)
+ {
+ }
+
+ ///
+ public void OnCompleted()
+ {
+ }
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/Internal/SwapDisposableTests.cs b/src/tests/ReactiveUI.Extensions.Tests/Internal/SwapDisposableTests.cs
new file mode 100644
index 0000000..78d3941
--- /dev/null
+++ b/src/tests/ReactiveUI.Extensions.Tests/Internal/SwapDisposableTests.cs
@@ -0,0 +1,70 @@
+// 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.Disposables;
+
+namespace ReactiveUI.Extensions.Tests.Internal;
+
+/// Tests for — verifies replacement disposes the previous
+/// inner, assigning after disposal immediately disposes the incoming value, and Dispose
+/// is idempotent.
+public class SwapDisposableTests
+{
+ /// Verifies that replacement disposes the previous inner.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenInnerReplaced_ThenPreviousIsDisposed()
+ {
+ using var holder = new SwapDisposable();
+ var firstDisposed = 0;
+ var secondDisposed = 0;
+ holder.Disposable = new ActionDisposable(() => firstDisposed++);
+ holder.Disposable = new ActionDisposable(() => secondDisposed++);
+
+ await Assert.That(firstDisposed).IsEqualTo(1);
+ await Assert.That(secondDisposed).IsEqualTo(0);
+ }
+
+ /// Verifies that the getter returns the currently-assigned inner.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenGetCurrent_ThenReturnsCurrent()
+ {
+ using var holder = new SwapDisposable();
+ var current = new ActionDisposable(static () => { });
+
+ holder.Disposable = current;
+
+ await Assert.That(holder.Disposable).IsSameReferenceAs(current);
+ }
+
+ /// Verifies that assigning after disposal immediately disposes the incoming value.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenSetAfterDispose_ThenIncomingIsDisposedImmediately()
+ {
+ var holder = new SwapDisposable();
+ holder.Dispose();
+ var late = 0;
+
+ holder.Disposable = new ActionDisposable(() => late++);
+
+ await Assert.That(late).IsEqualTo(1);
+ }
+
+ /// Verifies Dispose is idempotent across repeated calls.
+ /// A representing the asynchronous test operation.
+ [Test]
+ public async Task WhenDisposedTwice_ThenInnerDisposedOnce()
+ {
+ var holder = new SwapDisposable();
+ var disposed = 0;
+ holder.Disposable = new ActionDisposable(() => disposed++);
+
+ holder.Dispose();
+ holder.Dispose();
+
+ await Assert.That(disposed).IsEqualTo(1);
+ }
+}
diff --git a/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs b/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs
index 521edcb..30ad751 100644
--- a/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs
+++ b/src/tests/ReactiveUI.Extensions.Tests/ObservableSubscriptionExtensionsTests.cs
@@ -158,4 +158,66 @@ public async Task WhenWaitForErrorTimesOut_ThenTimeoutException()
var ex = Assert.Throws(call);
await Assert.That(ex).IsNotNull();
}
+
+ /// Verifies the single-arg