Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/Primitives.Async.Shared/AsyncContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,15 @@ public static AsyncContext From(ISequencer scheduler)
}

/// <summary>Gets the current asynchronous context associated with the calling thread.</summary>
/// <remarks>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.</remarks>
/// <remarks>
/// 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.
/// </remarks>
/// <returns>An <see cref="AsyncContext"/> representing the current asynchronous context. If a <see
/// cref="SynchronizationContext"/> is present, it is used; otherwise, the current <see cref="TaskScheduler"/> is
/// used.</returns>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static AsyncContext GetCurrent()
{
var currentSc = SynchronizationContext.Current;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ public SynchronizationContextSequencer(SynchronizationContext context) =>
Context = context ?? throw new ArgumentNullException(nameof(context));

/// <summary>Gets a sequencer for the current synchronization context.</summary>
/// <remarks>Coverage excludes the getter because the ambient context cannot be changed safely by parallel tests.</remarks>
/// <exception cref="InvalidOperationException">There is no current synchronization context.</exception>
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."));
}

/// <summary>Gets the synchronization context used to schedule work.</summary>
public SynchronizationContext Context { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,11 @@ await Assert.That(
false)).IsFalse();
}

/// <summary>Verifies current-context capture and explicit awaiter scheduling branches.</summary>
/// <summary>Verifies explicit awaiter scheduling branches.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ public partial class SyncLatestOperatorTests
[Test]
public async Task WhenSyncLatestOverEnumerable_ThenEmitsSnapshotOfLatestValues()
{
_ = Assert.Throws<ArgumentNullException>(static () =>
_ = new SyncLatestEnumerableSignal<int, int>(null!, static values => values[0]));
var first = Signal.Create<int>();
var second = Signal.Create<int>();
IObservableAsync<int>[] sources = [first.Values, second.Values];
List<IObservableAsync<int>> sources = [first.Values, second.Values];

List<int[]> snapshots = [];
await using var sub = await sources.SyncLatest().SubscribeAsync(
Expand Down
12 changes: 0 additions & 12 deletions src/tests/ReactiveUI.Primitives.Tests/SequencerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -559,18 +559,6 @@ private static async Task AssertSynchronizationContextSequencerValidatesAndRunsS
{
ImmediateSynchronizationContext synchronizationContext = new();
_ = Assert.Throws<ArgumentNullException>(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<ArgumentNullException>(() => synchronizationSequencer.Schedule(One, null!));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> 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();
}

/// <summary>Verifies direct from-async subscriptions cover constructor and synchronous completion paths.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,6 @@ public partial class SignalFactoriesTests
/// <summary>The divisor that selects the even values of a sequence.</summary>
private const int EvenDivisor = 2;

/// <summary>Delay used by the async enumerable cancellation test.</summary>
private const int AsyncEnumeratorDelayMilliseconds = 5000;

/// <summary>Timeout used while waiting for async enumerable disposal.</summary>
private const int AsyncEnumeratorDisposeTimeoutSeconds = 5;

/// <summary>Virtual clock due time for one-shot timers.</summary>
private const int AfterTicks = 5;

Expand Down Expand Up @@ -291,20 +285,23 @@ public async Task RetryResubscribesUntilSuccess()
}

/// <summary>Verifies async enumerable subscriptions cancel and dispose the enumerator.</summary>
/// <param name="testToken">The test cancellation token.</param>
/// <returns>A task that completes when the asynchronous assertions have run.</returns>
[Test]
public async Task AsyncEnumerableFactoryCancelsEnumeratorOnDispose()
[Timeout(30_000)]
public async Task AsyncEnumerableFactoryCancelsEnumeratorOnDispose(CancellationToken testToken)
{
var disposed = false;
List<int> values = [];
TaskCompletionSource firstValueObserved = new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource disposedSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);

async IAsyncEnumerable<int> Values([EnumeratorCancellation] CancellationToken token = default)
{
try
{
yield return FirstValue;
await Task.Delay(AsyncEnumeratorDelayMilliseconds, token);
await Task.Delay(Timeout.InfiniteTimeSpan, token);
yield return SecondValue;
}
finally
Expand All @@ -314,14 +311,17 @@ async IAsyncEnumerable<int> 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();
}
Expand Down
47 changes: 20 additions & 27 deletions src/tests/ReactiveUI.Primitives.Tests/SignalFromTaskTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,6 @@ public class SignalFromTaskTest
/// <summary>Delay used before checking that a task has started.</summary>
private const int InitialDelayMilliseconds = 500;

/// <summary>Delay before token cancellation is requested.</summary>
private const int TokenCancellationDelayMilliseconds = 1000;

/// <summary>Delay used to simulate cancellation cleanup.</summary>
private const int CleanupDelayMilliseconds = 5000;

/// <summary>
/// Time spent performing synchronous cancellation cleanup work. Kept short so the
/// blocking <see cref = "Thread.Sleep(int)"/> does not occupy a thread-pool thread long
Expand Down Expand Up @@ -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<RxVoid>(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)
{
Expand All @@ -728,14 +726,17 @@ await Task.Delay(CommandDelayMilliseconds, cts.Token)
{
RecordStatus(statusTrail, ref position, ExceptionShouldBeHere);
return Signal.Fail<RxVoid>(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);
Expand Down Expand Up @@ -786,20 +787,20 @@ await Task.Delay(CommandDelayMilliseconds, cts.Token)
public async Task SignalFromTask_T_HandlesTokenCancellation()
{
StatusTrail statusTrail = new();
TaskCompletionSource<CancellationTokenSource> cancellationReady =
new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource cleanupCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource finallyCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously);
var position = 0;
var fixture = Signal.FromTask<RxVoid>(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);
Expand All @@ -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);
Expand Down Expand Up @@ -1063,15 +1065,6 @@ private static void RecordCancellationCleanup(StatusTrail statusTrail, ref int p
RecordStatus(statusTrail, ref position, FinishedCancellingCommand);
}

/// <summary>Cancels the source after the token cancellation delay.</summary>
/// <param name = "cts">The cancellation source.</param>
/// <returns>A <see cref = "Task"/> representing the asynchronous operation.</returns>
private static async Task CancelAfterDelayAsync(CancellationTokenSource cts)
{
await Task.Delay(TokenCancellationDelayMilliseconds, cts.Token).ConfigureAwait(false);
await cts.CancelAsync().ConfigureAwait(false);
}

/// <summary>Observer that counts the notifications it received.</summary>
private class RecordingObserver : IObserver<int>
{
Expand Down
Loading