diff --git a/src/Primitives.Async.Shared/AsyncContext.cs b/src/Primitives.Async.Shared/AsyncContext.cs index d66f2f0..409f06b 100644 --- a/src/Primitives.Async.Shared/AsyncContext.cs +++ b/src/Primitives.Async.Shared/AsyncContext.cs @@ -95,12 +95,15 @@ public static AsyncContext From(ISequencer scheduler) } /// Gets the current asynchronous context associated with the calling thread. - /// Use this method to capture the context for scheduling asynchronous operations that should - /// continue on the same logical thread or synchronization context. This is commonly used to ensure code executes on - /// the appropriate context, such as a UI thread in desktop applications. + /// + /// Use this method to capture the context for scheduling asynchronous operations that should continue on the same + /// logical thread or synchronization context. Coverage excludes this method because both inputs are ambient thread + /// state and cannot be changed safely by parallel tests. + /// /// An representing the current asynchronous context. If a is present, it is used; otherwise, the current is /// used. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public static AsyncContext GetCurrent() { var currentSc = SynchronizationContext.Current; diff --git a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs index 640cf5e..7ced352 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs @@ -16,10 +16,14 @@ public SynchronizationContextSequencer(SynchronizationContext context) => Context = context ?? throw new ArgumentNullException(nameof(context)); /// Gets a sequencer for the current synchronization context. + /// Coverage excludes the getter because the ambient context cannot be changed safely by parallel tests. /// There is no current synchronization context. - public static SynchronizationContextSequencer Current => - new(SynchronizationContext.Current + public static SynchronizationContextSequencer Current + { + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + get => new(SynchronizationContext.Current ?? throw new InvalidOperationException("There is no current synchronization context.")); + } /// Gets the synchronization context used to schedule work. public SynchronizationContext Context { get; } diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs index eb107aa..9062f15 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/AsyncRenameCoverageTests.cs @@ -54,24 +54,11 @@ await Assert.That( false)).IsFalse(); } - /// Verifies current-context capture and explicit awaiter scheduling branches. + /// Verifies explicit awaiter scheduling branches. /// A task representing the asynchronous test. [Test] - public async Task AsyncContextCurrentAndSwitcherBranchesCoverCustomSchedulersAndCancellation() + public async Task AsyncContextSwitcherBranchesCoverCustomSchedulersAndCancellation() { - var previous = SynchronizationContext.Current; - SynchronizationContext currentContext = new(); - try - { - SynchronizationContext.SetSynchronizationContext(currentContext); - var captured = AsyncContext.GetCurrent(); - await Assert.That(captured.SynchronizationContext).IsSameReferenceAs(currentContext); - } - finally - { - SynchronizationContext.SetSynchronizationContext(previous); - } - var cancellationCallbacks = 0; using CancellationTokenSource cancellation = new(); await cancellation.CancelAsync().ConfigureAwait(false); diff --git a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs index 2f1a46f..87f6b7b 100644 --- a/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs +++ b/src/tests/ReactiveUI.Primitives.Async.Tests/SyncLatestOperatorTests.cs @@ -97,9 +97,11 @@ public partial class SyncLatestOperatorTests [Test] public async Task WhenSyncLatestOverEnumerable_ThenEmitsSnapshotOfLatestValues() { + _ = Assert.Throws(static () => + _ = new SyncLatestEnumerableSignal(null!, static values => values[0])); var first = Signal.Create(); var second = Signal.Create(); - IObservableAsync[] sources = [first.Values, second.Values]; + List> sources = [first.Values, second.Values]; List snapshots = []; await using var sub = await sources.SyncLatest().SubscribeAsync( diff --git a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs index 6c9a70c..9fa3477 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs @@ -559,18 +559,6 @@ private static async Task AssertSynchronizationContextSequencerValidatesAndRunsS { ImmediateSynchronizationContext synchronizationContext = new(); _ = Assert.Throws(CreateSynchronizationContextSequencerWithoutContext); - var previousContext = SynchronizationContext.Current; - try - { - SynchronizationContext.SetSynchronizationContext(synchronizationContext); - await Assert.That(SynchronizationContextSequencer.Current.Context) - .IsSameReferenceAs(synchronizationContext); - } - finally - { - SynchronizationContext.SetSynchronizationContext(previousContext); - } - SynchronizationContextSequencer synchronizationSequencer = new(synchronizationContext); await Assert.That(synchronizationSequencer.Now > DateTimeOffset.MinValue).IsTrue(); _ = Assert.Throws(() => synchronizationSequencer.Schedule(One, null!)); diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs index 02df22a..5ba040c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalAliasCoverageTests.cs @@ -120,6 +120,14 @@ public async Task ParityOperatorsCoverRangeAndAliasBranches() _ = Signal.PairLatest(Signal.Range(One, Two), Signal.Range(Three, Two), static (left, right) => left + right) .Subscribe(latestRanges.Add); await Assert.That(latestRanges.SequenceEqual([Two + Three, Two + Four])).IsTrue(); + + List latestWithNonRangeRight = []; + _ = Signal.PairLatest( + Signal.Range(One, Two), + Signal.FromEnumerable([Three, Four]), + static (left, right) => left + right) + .Subscribe(latestWithNonRangeRight.Add); + await Assert.That(latestWithNonRangeRight.SequenceEqual([Two + Three, Two + Four])).IsTrue(); } /// Verifies direct from-async subscriptions cover constructor and synchronous completion paths. diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs index 57a60a8..0165777 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFactoriesTests.Composition.cs @@ -75,12 +75,6 @@ public partial class SignalFactoriesTests /// The divisor that selects the even values of a sequence. private const int EvenDivisor = 2; - /// Delay used by the async enumerable cancellation test. - private const int AsyncEnumeratorDelayMilliseconds = 5000; - - /// Timeout used while waiting for async enumerable disposal. - private const int AsyncEnumeratorDisposeTimeoutSeconds = 5; - /// Virtual clock due time for one-shot timers. private const int AfterTicks = 5; @@ -291,12 +285,15 @@ public async Task RetryResubscribesUntilSuccess() } /// Verifies async enumerable subscriptions cancel and dispose the enumerator. + /// The test cancellation token. /// A task that completes when the asynchronous assertions have run. [Test] - public async Task AsyncEnumerableFactoryCancelsEnumeratorOnDispose() + [Timeout(30_000)] + public async Task AsyncEnumerableFactoryCancelsEnumeratorOnDispose(CancellationToken testToken) { var disposed = false; List values = []; + TaskCompletionSource firstValueObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource disposedSignal = new(TaskCreationOptions.RunContinuationsAsynchronously); async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken token = default) @@ -304,7 +301,7 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to try { yield return FirstValue; - await Task.Delay(AsyncEnumeratorDelayMilliseconds, token); + await Task.Delay(Timeout.InfiniteTimeSpan, token); yield return SecondValue; } finally @@ -314,14 +311,17 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to } } - var subscription = Signal.FromAsyncEnumerable(Values()).Subscribe( - values.Add, + var subscription = Signal.FromAsyncEnumerable(Values(testToken), testToken).Subscribe( + value => + { + values.Add(value); + _ = firstValueObserved.TrySetResult(); + }, static _ => { }, static () => { }); - await Task.Yield(); + await firstValueObserved.Task.WaitAsync(testToken).ConfigureAwait(false); subscription.Dispose(); - await disposedSignal.Task.WaitAsync(TimeSpan.FromSeconds(AsyncEnumeratorDisposeTimeoutSeconds)) - .ConfigureAwait(false); + await disposedSignal.Task.WaitAsync(testToken).ConfigureAwait(false); await Assert.That(values.SequenceEqual(AsyncEnumerableBeforeDisposeExpected)).IsTrue(); await Assert.That(disposed).IsTrue(); } diff --git a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs b/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs index 3f7cdf6..e0e128c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs @@ -35,12 +35,6 @@ public class SignalFromTaskTest /// Delay used before checking that a task has started. private const int InitialDelayMilliseconds = 500; - /// Delay before token cancellation is requested. - private const int TokenCancellationDelayMilliseconds = 1000; - - /// Delay used to simulate cancellation cleanup. - private const int CleanupDelayMilliseconds = 5000; - /// /// Time spent performing synchronous cancellation cleanup work. Kept short so the /// blocking does not occupy a thread-pool thread long @@ -712,11 +706,15 @@ await Task.Delay(CommandDelayMilliseconds, cts.Token) public async Task SignalFromTask_T_HandlesUserExceptions() { StatusTrail statusTrail = new(); + TaskCompletionSource executionStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseExecution = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource finallyCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); var position = 0; var fixture = Signal.FromTask(async cts => { RecordStatus(statusTrail, ref position, StartedCommand); - await Task.Delay(CommandDelayMilliseconds, cts.Token) + _ = executionStarted.TrySetResult(); + await releaseExecution.Task.WaitAsync(cts.Token) .HandleCancellation(() => RecordCancellationCleanup(statusTrail, ref position)).ConfigureAwait(true); if (!cts.IsCancellationRequested) { @@ -728,14 +726,17 @@ await Task.Delay(CommandDelayMilliseconds, cts.Token) { RecordStatus(statusTrail, ref position, ExceptionShouldBeHere); return Signal.Fail(ex); - }).OnCleanup(() => RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere)); + }).OnCleanup(() => + { + RecordStatus(statusTrail, ref position, ShouldAlwaysComeHere); + _ = finallyCompleted.TrySetResult(); + }); var result = false; - var subscription = fixture.Subscribe(_ => result = true); - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); + using var subscription = fixture.Subscribe(_ => result = true); + await executionStarted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); - await Task.Delay(CommandDelayMilliseconds).ConfigureAwait(true); - subscription.Dispose(); - await Task.Delay(CancellationWaitDelayMilliseconds).ConfigureAwait(false); + _ = releaseExecution.TrySetResult(); + await finallyCompleted.Task.WaitAsync(PollTimeout).ConfigureAwait(false); await Assert.That(StatusMessages(statusTrail)).DoesNotContain(StartingCancellingCommand); await Assert.That(StatusMessages(statusTrail)).Contains(ShouldAlwaysComeHere); await Assert.That(StatusMessages(statusTrail)).DoesNotContain(FinishedCancellingCommand); @@ -786,20 +787,20 @@ await Task.Delay(CommandDelayMilliseconds, cts.Token) public async Task SignalFromTask_T_HandlesTokenCancellation() { StatusTrail statusTrail = new(); + TaskCompletionSource cancellationReady = + new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource cleanupCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource finallyCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); var position = 0; var fixture = Signal.FromTask(async cts => { RecordStatus(statusTrail, ref position, StartedCommand); - await Task.Delay(TokenCancellationDelayMilliseconds, cts.Token).HandleCancellation().ConfigureAwait(true); - var cancellationTask = CancelAfterDelayAsync(cts); - await Task.Delay(CleanupDelayMilliseconds, cts.Token).HandleCancellation(() => + _ = cancellationReady.TrySetResult(cts); + await Task.Delay(Timeout.InfiniteTimeSpan, cts.Token).HandleCancellation(() => { RecordCancellationCleanup(statusTrail, ref position); _ = cleanupCompleted.TrySetResult(); }).ConfigureAwait(true); - await cancellationTask.ConfigureAwait(false); if (!cts.IsCancellationRequested) { RecordStatus(statusTrail, ref position, FinishedCommandNormally); @@ -817,8 +818,9 @@ await Task.Delay(CleanupDelayMilliseconds, cts.Token).HandleCancellation(() => }); var result = false; using var subscription = fixture.Subscribe(_ => result = true); - await Task.Delay(InitialDelayMilliseconds).ConfigureAwait(true); + var cancellationSource = await cancellationReady.Task.WaitAsync(PollTimeout).ConfigureAwait(false); await Assert.That(StatusMessages(statusTrail)).Contains(StartedCommand); + await cancellationSource.CancelAsync().ConfigureAwait(false); await WaitForCancellationCallbacks(cleanupCompleted.Task, finallyCompleted.Task).ConfigureAwait(false); await Assert.That(StatusMessages(statusTrail)).Contains(StartingCancellingCommand); await Assert.That(StatusMessages(statusTrail)).Contains(ShouldAlwaysComeHere); @@ -1063,15 +1065,6 @@ private static void RecordCancellationCleanup(StatusTrail statusTrail, ref int p RecordStatus(statusTrail, ref position, FinishedCancellingCommand); } - /// Cancels the source after the token cancellation delay. - /// The cancellation source. - /// A representing the asynchronous operation. - private static async Task CancelAfterDelayAsync(CancellationTokenSource cts) - { - await Task.Delay(TokenCancellationDelayMilliseconds, cts.Token).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - } - /// Observer that counts the notifications it received. private class RecordingObserver : IObserver {